diff --git a/.rubocop.yml b/.rubocop.yml index 2d9f108..64ecb10 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,7 @@ AllCops: TargetRubyVersion: 3.2 + Exclude: + - 'test/**/*' Metrics/MethodLength: Max: 20 diff --git a/lib/datadog/lambda.rb b/lib/datadog/lambda.rb index 4eee6d4..90ae6e9 100644 --- a/lib/datadog/lambda.rb +++ b/lib/datadog/lambda.rb @@ -13,6 +13,7 @@ require 'datadog/lambda/trace/listener' require 'datadog/lambda/utils/logger' require 'datadog/lambda/utils/extension' +require 'datadog/lambda/utils/version' require 'datadog/lambda/trace/patch_http' require 'json' require 'datadog/lambda/version' @@ -29,6 +30,8 @@ module Lambda # Configures Datadog's APM tracer with lambda specific defaults. # Same options can be given as Datadog.configure in tracer # See https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#quickstart-for-ruby-applications + # + # rubocop:disable Metrics/AbcSize def self.configure_apm require 'datadog/tracing' require 'datadog/tracing/transport/io' @@ -48,8 +51,12 @@ def self.configure_apm c.tracing.instrument :aws if trace_managed_services? yield(c) if block_given? + + # Activation is gated by AppSec.enabled? at runtime — this only registers the integration + c.appsec.instrument(:aws_lambda) end end + # rubocop:enable Metrics/AbcSize # Wrap the body of a lambda invocation # @param event [Object] event sent to lambda @@ -113,10 +120,8 @@ def self.gen_enhanced_tags(context) resource: context.function_name, datadog_lambda: Datadog::Lambda::VERSION::STRING.to_sym } - begin - tags[:dd_trace] = Gem.loaded_specs['datadog'].version - rescue StandardError - Datadog::Utils.logger.debug 'datadog unavailable' + if (dd_trace = Datadog::Utils.dd_trace_version) + tags[:dd_trace] = dd_trace end # If we have an alias... unless function_alias.nil? diff --git a/lib/datadog/lambda/appsec.rb b/lib/datadog/lambda/appsec.rb new file mode 100644 index 0000000..f1a5db0 --- /dev/null +++ b/lib/datadog/lambda/appsec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative 'appsec/request' + +module Datadog + module Lambda + # AppSec integration for AWS Lambda invocations. + module AppSec + class << self + def on_start(event, trace:, span:, inferred_span: nil) + @request = nil + @inferred_span = inferred_span + return unless enabled? + + context = create_context(trace, span) + return unless Datadog::AppSec::Context.active + + @request = Request.from_event(event) + + payload = Datadog::AppSec::Instrumentation::Gateway::DataContainer.new( + event, context: context + ) + Datadog::AppSec::Instrumentation.gateway.push('aws_lambda.request.start', payload) + rescue StandardError => e + Datadog::Utils.logger.debug "failed to start AppSec: #{e}" + end + + def on_finish(response) + return unless enabled? + + context = Datadog::AppSec::Context.active + return unless context + + payload = Datadog::AppSec::Instrumentation::Gateway::DataContainer.new( + response, context: context + ) + + Datadog::AppSec::Instrumentation.gateway.push('aws_lambda.response.start', payload) + Datadog::AppSec::Event.record(context, request: @request) + copy_appsec_span_tags(context.span, @inferred_span) + + context.export_metrics + context.export_request_telemetry + rescue StandardError => e + Datadog::Utils.logger.debug "failed to finish AppSec: #{e}" + ensure + Datadog::AppSec::Context.deactivate if context + end + + private + + def enabled? + defined?(Datadog::AppSec) && + Datadog::AppSec.respond_to?(:enabled?) && + Datadog::AppSec.enabled? + end + + # NOTE: _dd.appsec.json is required on both the service-entry span + # and the inferred span. + def copy_appsec_span_tags(source_span, destination_span) + return unless source_span && destination_span + + appsec_json = source_span.get_tag('_dd.appsec.json') + destination_span.set_tag('_dd.appsec.json', appsec_json) if appsec_json + + appsec_enabled = source_span.get_metric('_dd.appsec.enabled') + destination_span.set_metric('_dd.appsec.enabled', appsec_enabled) if appsec_enabled + end + + def create_context(trace, span) + return if trace.nil? || span.nil? + + security_engine = Datadog::AppSec.security_engine + return unless security_engine + + context = Datadog::AppSec::Context.new(trace, span, security_engine.new_runner) + Datadog::AppSec::Context.activate(context) + + span.set_metric(Datadog::AppSec::Ext::TAG_APPSEC_ENABLED, 1) + + context + end + end + end + end +end diff --git a/lib/datadog/lambda/appsec/request.rb b/lib/datadog/lambda/appsec/request.rb new file mode 100644 index 0000000..c74d55a --- /dev/null +++ b/lib/datadog/lambda/appsec/request.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Datadog + module Lambda + module AppSec + # Minimal request object for AppSec event recording. + # + # WARNING: It's a minimal data for interface compliance + # + # @see Datadog::AppSec::Event.record + # @see Datadog::AppSec::Contrib::Rack::Gateway::Request + class Request + attr_reader :host, :user_agent, :remote_addr, :headers + + class << self + def from_event(event) + headers = normalize_headers(event) + remote_addres = event.dig('requestContext', 'identity', 'sourceIp') || + event.dig('requestContext', 'http', 'sourceIp') + + new( + host: headers['host'], + user_agent: headers['user-agent'], + remote_addr: remote_addres, + headers: headers + ) + end + + private + + def normalize_headers(event) + event.fetch('headers', {}).each_with_object({}) do |(key, value), hash| + hash[key.downcase] = value + end + end + end + + def initialize(host:, user_agent:, remote_addr:, headers:) + @host = host + @user_agent = user_agent + @remote_addr = remote_addr + @headers = headers + end + end + end + end +end diff --git a/lib/datadog/lambda/event_source.rb b/lib/datadog/lambda/event_source.rb new file mode 100644 index 0000000..93d14ed --- /dev/null +++ b/lib/datadog/lambda/event_source.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# +# Unless explicitly stated otherwise all files in this repository are licensed +# under the Apache License Version 2.0. +# +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2026 Datadog, Inc. +# + +require_relative 'event_source/api_gateway_v1' +require_relative 'event_source/api_gateway_v2' + +module Datadog + module Lambda + # Detects and parses Lambda event payloads into a uniform interface. + module EventSource + SOURCES = [ApiGatewayV1, ApiGatewayV2].freeze + + def self.for(event) + klass = SOURCES.find { |source| source.match?(event) } + klass&.new(event) + end + end + end +end diff --git a/lib/datadog/lambda/event_source/api_gateway_v1.rb b/lib/datadog/lambda/event_source/api_gateway_v1.rb new file mode 100644 index 0000000..aeba8a7 --- /dev/null +++ b/lib/datadog/lambda/event_source/api_gateway_v1.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# +# Unless explicitly stated otherwise all files in this repository are licensed +# under the Apache License Version 2.0. +# +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2026 Datadog, Inc. +# + +module Datadog + module Lambda + module EventSource + # Parses API Gateway REST API (v1) Lambda proxy integration events + # into a uniform interface. + # + # @see https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format + class ApiGatewayV1 + class << self + def match?(payload) + api_gateway?(payload) && payload.key?('httpMethod') + end + + private + + def api_gateway?(payload) + payload.is_a?(Hash) && + payload.key?('requestContext') && payload['requestContext'].key?('stage') + end + end + + def initialize(payload) + @payload = payload + @request_context = payload.fetch('requestContext', {}) + end + + def method = @payload['httpMethod'] + def path = @payload.fetch('path', '/') + def resource_path = @request_context.fetch('resourcePath', path) + def domain = @request_context['domainName'] + def api_id = @request_context['apiId'] + def stage = @request_context['stage'] + def request_time_ms = @request_context['requestTimeEpoch'] + def user_agent = @request_context.dig('identity', 'userAgent') + + def http_url + return path unless domain + + "https://#{domain}#{path}" + end + end + end + end +end diff --git a/lib/datadog/lambda/event_source/api_gateway_v2.rb b/lib/datadog/lambda/event_source/api_gateway_v2.rb new file mode 100644 index 0000000..37b2c43 --- /dev/null +++ b/lib/datadog/lambda/event_source/api_gateway_v2.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# +# Unless explicitly stated otherwise all files in this repository are licensed +# under the Apache License Version 2.0. +# +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2026 Datadog, Inc. +# + +module Datadog + module Lambda + module EventSource + # Parses API Gateway HTTP API (v2) Lambda proxy integration events + # into a uniform interface. + # + # @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.proxy-format + class ApiGatewayV2 + class << self + def match?(payload) + api_gateway?(payload) && payload.key?('routeKey') + end + + private + + def api_gateway?(payload) + payload.is_a?(Hash) && + payload.key?('requestContext') && payload['requestContext'].key?('stage') + end + end + + def initialize(payload) + @payload = payload + @request_context = payload.fetch('requestContext', {}) + @http = @request_context.fetch('http', {}) + end + + def method = @http['method'] + def path = @payload.fetch('rawPath', '/') + def resource_path = @payload['routeKey']&.sub(/\A[A-Z]+ /, '') || path + def domain = @request_context['domainName'] + def api_id = @request_context['apiId'] + def stage = @request_context['stage'] + def request_time_ms = @request_context['timeEpoch'] + def user_agent = @http['userAgent'] + + def http_url + return path unless domain + + "https://#{domain}#{path}" + end + end + end + end +end diff --git a/lib/datadog/lambda/inferred_span.rb b/lib/datadog/lambda/inferred_span.rb new file mode 100644 index 0000000..a9389c7 --- /dev/null +++ b/lib/datadog/lambda/inferred_span.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# Unless explicitly stated otherwise all files in this repository are licensed +# under the Apache License Version 2.0. +# +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2026 Datadog, Inc. + +require_relative 'event_source' +require_relative 'inferred_span/api_gateway_v1' +require_relative 'inferred_span/api_gateway_v2' + +module Datadog + module Lambda + # Creates inferred spans representing upstream services + # in the Lambda invocation path (e.g. API Gateway). + # + # @see https://docs.datadoghq.com/tracing/trace_collection/proxy_setup/apigateway/ + module InferredSpan + ARN_REGION_INDEX = 3 + ARN_SPLIT_LIMIT = 5 + + class << self + def try_create(event_source, request_context, trace_digest) + return unless event_source + + inferred = case event_source + when EventSource::ApiGatewayV1 then ApiGatewayV1.new(event_source) + when EventSource::ApiGatewayV2 then ApiGatewayV2.new(event_source) + end + return unless inferred + + start_span(inferred, request_context: request_context, trace_digest: trace_digest) + rescue StandardError => e + Datadog::Utils.logger.debug "failed to create inferred span: #{e}" + nil + end + + private + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def start_span(event_source, request_context:, trace_digest:) + resource = "#{event_source.method} #{event_source.resource_path}" + + tags = { + 'http.method' => event_source.method, + 'http.url' => event_source.http_url, + 'http.route' => event_source.resource_path, + 'endpoint' => event_source.path, + 'resource_names' => resource, + 'span.kind' => 'server', + 'apiid' => event_source.api_id, + 'apiname' => event_source.api_id, + 'stage' => event_source.stage, + 'request_id' => request_context.aws_request_id, + 'dd_resource_key' => resource_key_for(event_source, request_context), + 'http.useragent' => event_source.user_agent, + '_inferred_span.synchronicity' => 'sync', + '_inferred_span.tag_source' => 'self' + } + tags.compact! + + options = { service: event_source.domain, resource: resource, type: 'web', tags: tags } + options[:continue_from] = trace_digest if trace_digest + options[:start_time] = ms_to_time(event_source.request_time_ms) if event_source.request_time_ms + + span = Datadog::Tracing.trace(event_source.span_name, **options) + span.set_metric('_dd._inferred_span', 1.0) + span + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def resource_key_for(event_source, request_context) + arn = request_context.invoked_function_arn.to_s + return unless arn.include?(':') + + region = arn.split(':', ARN_SPLIT_LIMIT)[ARN_REGION_INDEX] + return unless event_source.api_id && event_source.stage + + stage_path = "/#{event_source.arn_path_prefix}/#{event_source.api_id}/stages/#{event_source.stage}" + "arn:aws:apigateway:#{region}::#{stage_path}" + end + + def ms_to_time(milliseconds) + Time.at(milliseconds / 1000.0) + end + end + end + end +end diff --git a/lib/datadog/lambda/inferred_span/api_gateway_v1.rb b/lib/datadog/lambda/inferred_span/api_gateway_v1.rb new file mode 100644 index 0000000..a3d1747 --- /dev/null +++ b/lib/datadog/lambda/inferred_span/api_gateway_v1.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Unless explicitly stated otherwise all files in this repository are licensed +# under the Apache License Version 2.0. +# +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2026 Datadog, Inc. + +require 'forwardable' + +module Datadog + module Lambda + module InferredSpan + # Wraps EventSource::ApiGatewayV1 with additional span-specific attributes. + class ApiGatewayV1 + extend Forwardable + + def_delegators :@event_source, + :method, :path, :resource_path, :domain, + :api_id, :stage, :request_time_ms, :user_agent, + :http_url + + def initialize(event_source) + @event_source = event_source + end + + def span_name = 'aws.apigateway' + def arn_path_prefix = 'restapis' + end + end + end +end diff --git a/lib/datadog/lambda/inferred_span/api_gateway_v2.rb b/lib/datadog/lambda/inferred_span/api_gateway_v2.rb new file mode 100644 index 0000000..7e8afae --- /dev/null +++ b/lib/datadog/lambda/inferred_span/api_gateway_v2.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Unless explicitly stated otherwise all files in this repository are licensed +# under the Apache License Version 2.0. +# +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2026 Datadog, Inc. + +require 'forwardable' + +module Datadog + module Lambda + module InferredSpan + # Wraps EventSource::ApiGatewayV2 with additional span-specific attributes. + class ApiGatewayV2 + extend Forwardable + + def_delegators :@event_source, + :method, :path, :resource_path, :domain, + :api_id, :stage, :request_time_ms, :user_agent, + :http_url + + def initialize(event_source) + @event_source = event_source + end + + def span_name = 'aws.httpapi' + def arn_path_prefix = 'apis' + end + end + end +end diff --git a/lib/datadog/lambda/trace/listener.rb b/lib/datadog/lambda/trace/listener.rb index e4ac564..93ee25f 100644 --- a/lib/datadog/lambda/trace/listener.rb +++ b/lib/datadog/lambda/trace/listener.rb @@ -11,12 +11,15 @@ require 'datadog/lambda/trace/context' require 'datadog/lambda/trace/patch_http' require 'datadog/lambda/trace/ddtrace' +require 'datadog/lambda/event_source' +require 'datadog/lambda/inferred_span' +require 'datadog/lambda/appsec' +require 'datadog/lambda/utils/version' module Datadog module Trace # TraceListener tracks tracing context information class Listener - @trace = nil def initialize(handler_name:, function_name:, patch_http:, merge_xray_traces:) @handler_name = handler_name @@ -26,8 +29,11 @@ def initialize(handler_name:, function_name:, patch_http:, Datadog::Trace.patch_http if patch_http end - # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def on_start(event:, request_context:, cold_start:) + @span = nil + @inferred_span = nil + trace_context = Datadog::Trace.extract_trace_context(event, @merge_xray_traces) Datadog::Trace.trace_context = trace_context Datadog::Utils.logger.debug "extracted trace context #{trace_context}" @@ -38,44 +44,75 @@ def on_start(event:, request_context:, cold_start:) context = Datadog::Trace.trace_context source = context[:source] if context options[:tags]['_dd.parent_source'] = source if source && source != 'ddtrace' - options[:resource] = 'dd-tracer-serverless-span' - options[:service] = 'aws.lambda' + options[:resource] = @function_name || 'aws.lambda' + options[:service] = Datadog.configuration.service || @function_name || 'aws.lambda' options[:type] = 'serverless' trace_digest = Datadog::Utils.send_start_invocation_request(event:, request_context:) - # Only continue trace from a new one if it exist, or else, - # it will create a new trace, which is not ideal here. - options[:continue_from] = trace_digest if trace_digest - @trace = Datadog::Tracing.trace('aws.lambda', **options) + event_source = Datadog::Lambda::EventSource.for(event) + @inferred_span = Datadog::Lambda::InferredSpan.try_create(event_source, request_context, trace_digest) + options[:continue_from] = trace_digest if trace_digest && @inferred_span.nil? + + @span = Datadog::Tracing.trace('aws.lambda', **options) + set_http_tags(@span, event_source) if event_source Datadog::Trace.apply_datadog_trace_context(Datadog::Trace.trace_context) + Datadog::Lambda::AppSec.on_start( + event, trace: Datadog::Tracing.active_trace, span: @span, inferred_span: @inferred_span + ) end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def on_end(response:, request_context:) - Datadog::Utils.send_end_invocation_request(response:, span_id: @trace.id, request_context:) - @trace&.finish + Datadog::Lambda::AppSec.on_finish(response) + Datadog::Utils.send_end_invocation_request(span_id: @span.id, response:, request_context:) + + if response.is_a?(Hash) && (status = response[:statusCode]) + @span&.set_tag('http.status_code', status) + @inferred_span&.set_tag('http.status_code', status) + end + + # NOTE: lambda span must finish before inferred span (its parent) + @span&.finish + @inferred_span&.finish end private + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def get_option_tags(request_context:, cold_start:) function_arn = request_context.invoked_function_arn.to_s.downcase tk = function_arn.split(':') function_arn = tk.length > 7 ? tk[0, 7].join(':') : function_arn function_version = tk.length > 7 ? tk[7] : '$LATEST' function_name = request_context.function_name - { + + result = { tags: { + 'span.kind' => 'server', cold_start:, function_arn:, function_version:, request_id: request_context.aws_request_id, functionname: function_name.nil? || function_name.empty? ? nil : function_name.downcase, - resource_names: function_name + resource_names: function_name, + datadog_lambda: Datadog::Lambda::VERSION::STRING } } + + if (dd_trace = Datadog::Utils.dd_trace_version) + result[:tags][:dd_trace] = dd_trace + end + + result + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def set_http_tags(span, event_source) + span.set_tag('http.method', event_source.method) + span.set_tag('http.url', event_source.http_url) + span.set_tag('http.useragent', event_source.user_agent) end end end diff --git a/lib/datadog/lambda/utils/version.rb b/lib/datadog/lambda/utils/version.rb new file mode 100644 index 0000000..b306c8d --- /dev/null +++ b/lib/datadog/lambda/utils/version.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# +# Unless explicitly stated otherwise all files in this repository are licensed +# under the Apache License Version 2.0. +# +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2026 Datadog, Inc. +# + +require_relative 'logger' + +module Datadog + # Utils contains utility functions shared between modules + module Utils + def self.dd_trace_version + Gem.loaded_specs['datadog']&.version&.to_s + rescue StandardError + logger.debug('datadog unavailable') + nil + end + end +end diff --git a/test/datadog/lambda.spec.rb b/test/datadog/lambda.spec.rb index b8354cf..8e7fd0e 100644 --- a/test/datadog/lambda.spec.rb +++ b/test/datadog/lambda.spec.rb @@ -145,6 +145,15 @@ end end + context 'when datadog gem is unavailable' do + before { allow(Datadog::Utils).to receive(:dd_trace_version).and_return(nil) } + + it 'excludes dd_trace from enhanced tags' do + tags = Datadog::Lambda.gen_enhanced_tags(ctx) + expect(tags).not_to have_key(:dd_trace) + end + end + describe '#metric' do context 'when extension is running' do subject(:lambda_module) { Datadog::Lambda } diff --git a/test/datadog/lambda/appsec.spec.rb b/test/datadog/lambda/appsec.spec.rb new file mode 100644 index 0000000..0e7e336 --- /dev/null +++ b/test/datadog/lambda/appsec.spec.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require 'datadog/lambda' +require 'datadog/lambda/appsec' + +RSpec.describe Datadog::Lambda::AppSec do + before do + allow(Datadog::AppSec::Instrumentation).to receive(:gateway).and_return(gateway) + allow(gateway).to receive(:push) + end + + let(:gateway) { instance_double(Datadog::AppSec::Instrumentation::Gateway) } + let(:appsec_context) do + instance_double( + Datadog::AppSec::Context, + span: instance_double(Datadog::Tracing::SpanOperation, get_tag: nil, get_metric: nil), + state: {}, + export_metrics: nil, + export_request_telemetry: nil + ) + end + + describe '.on_start' do + subject(:on_start) { described_class.on_start(event, trace: trace, span: span) } + + let(:event) { { 'httpMethod' => 'GET', 'path' => '/' } } + let(:trace) { instance_double(Datadog::Tracing::TraceOperation) } + let(:span) { instance_double(Datadog::Tracing::SpanOperation, set_metric: nil) } + + context 'when appsec is disabled' do + before { allow(Datadog::AppSec).to receive(:enabled?).and_return(false) } + + it 'does not push to gateway' do + on_start + + expect(gateway).not_to have_received(:push) + end + end + + context 'when appsec is enabled' do + before do + allow(Datadog::AppSec).to receive(:enabled?).and_return(true) + allow(Datadog::AppSec).to receive(:security_engine).and_return(security_engine) + allow(Datadog::AppSec::Context).to receive(:activate) + allow(Datadog::AppSec::Context).to receive(:active).and_return(appsec_context) + end + + let(:security_engine) { instance_double(Datadog::AppSec::SecurityEngine::Engine, new_runner: waf_runner) } + let(:waf_runner) { instance_double(Datadog::AppSec::SecurityEngine::Runner) } + + it 'marks span as appsec-enabled' do + on_start + + expect(span).to have_received(:set_metric).with(Datadog::AppSec::Ext::TAG_APPSEC_ENABLED, 1) + end + + it 'pushes event to gateway' do + on_start + + expect(gateway).to have_received(:push).with( + 'aws_lambda.request.start', kind_of(Datadog::AppSec::Instrumentation::Gateway::DataContainer) + ) + end + + context 'when security_engine is nil' do + before do + allow(Datadog::AppSec).to receive(:security_engine).and_return(nil) + allow(Datadog::AppSec::Context).to receive(:active).and_return(nil) + end + + it 'skips context activation and gateway push' do + on_start + + aggregate_failures('skipped activation') do + expect(Datadog::AppSec::Context).not_to have_received(:activate) + expect(gateway).not_to have_received(:push) + end + end + end + + context 'when trace is nil' do + subject(:on_start) { described_class.on_start(event, trace: nil, span: span) } + + before { allow(Datadog::AppSec::Context).to receive(:active).and_return(nil) } + + it 'skips context activation and gateway push' do + on_start + + aggregate_failures('skipped activation') do + expect(Datadog::AppSec::Context).not_to have_received(:activate) + expect(gateway).not_to have_received(:push) + end + end + end + + context 'when span is nil' do + subject(:on_start) { described_class.on_start(event, trace: trace, span: nil) } + + before { allow(Datadog::AppSec::Context).to receive(:active).and_return(nil) } + + it 'skips context activation and gateway push' do + on_start + + aggregate_failures('skipped activation') do + expect(Datadog::AppSec::Context).not_to have_received(:activate) + expect(gateway).not_to have_received(:push) + end + end + end + + context 'when an error occurs' do + before { allow(Datadog::AppSec::Context).to receive(:new).and_raise(StandardError, 'boom') } + + it { expect { on_start }.not_to raise_error } + end + end + end + + describe '.on_finish' do + subject(:on_finish) { described_class.on_finish(response) } + + let(:response) { { 'statusCode' => 200 } } + + context 'when appsec is disabled' do + before do + allow(Datadog::AppSec).to receive(:enabled?).and_return(false) + allow(Datadog::AppSec::Context).to receive(:active).and_return(appsec_context) + end + + it 'does not push to gateway' do + on_finish + + expect(gateway).not_to have_received(:push) + end + end + + context 'when no active context exists' do + before do + allow(Datadog::AppSec).to receive(:enabled?).and_return(true) + allow(Datadog::AppSec::Context).to receive(:active).and_return(nil) + end + + it 'does not push to gateway' do + on_finish + + expect(gateway).not_to have_received(:push) + end + end + + context 'when active context exists' do + before do + allow(Datadog::AppSec).to receive(:enabled?).and_return(true) + allow(Datadog::AppSec::Context).to receive(:active).and_return(appsec_context) + allow(Datadog::AppSec::Context).to receive(:deactivate) + allow(Datadog::AppSec::Event).to receive(:record) + end + + it 'pushes response and records events' do + on_finish + + aggregate_failures('response processing') do + expect(gateway).to have_received(:push).with( + 'aws_lambda.response.start', kind_of(Datadog::AppSec::Instrumentation::Gateway::DataContainer) + ) + expect(Datadog::AppSec::Event).to have_received(:record).with(appsec_context, request: nil) + end + end + + it 'exports telemetry and deactivates' do + on_finish + + aggregate_failures('AppSec deactivation') do + expect(appsec_context).to have_received(:export_metrics) + expect(appsec_context).to have_received(:export_request_telemetry) + expect(Datadog::AppSec::Context).to have_received(:deactivate) + end + end + + context 'when a security event occurs' do + before do + allow(Datadog::AppSec).to receive(:security_engine).and_return(security_engine) + allow(Datadog::AppSec::Context).to receive(:activate) + + described_class.on_start(event, trace: trace, span: span) + end + + let(:event) do + { + 'headers' => { 'Host' => 'example.com', 'User-Agent' => 'TestBot' }, + 'requestContext' => { 'identity' => { 'sourceIp' => '1.2.3.4' } } + } + end + let(:trace) { instance_double(Datadog::Tracing::TraceOperation) } + let(:span) { instance_double(Datadog::Tracing::SpanOperation, set_metric: nil) } + let(:security_engine) { instance_double(Datadog::AppSec::SecurityEngine::Engine, new_runner: waf_runner) } + let(:waf_runner) { instance_double(Datadog::AppSec::SecurityEngine::Runner) } + + it 'records security event' do + on_finish + + expect(Datadog::AppSec::Event).to have_received(:record).with( + appsec_context, request: kind_of(Datadog::Lambda::AppSec::Request) + ) + end + end + + context 'when an error occurs' do + before { allow(gateway).to receive(:push).and_raise(StandardError, 'boom') } + + it 'still deactivates the context' do + on_finish + + expect(Datadog::AppSec::Context).to have_received(:deactivate) + end + end + end + end +end diff --git a/test/datadog/lambda/appsec/request.spec.rb b/test/datadog/lambda/appsec/request.spec.rb new file mode 100644 index 0000000..eaf9524 --- /dev/null +++ b/test/datadog/lambda/appsec/request.spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'datadog/lambda/appsec/request' + +RSpec.describe Datadog::Lambda::AppSec::Request do + subject(:request) { described_class.from_event(event) } + + let(:event) do + { + 'headers' => { 'Host' => 'example.com', 'User-Agent' => 'TestBot/1.0', 'Accept' => 'text/html' }, + 'requestContext' => { + 'identity' => { 'sourceIp' => '10.0.0.1' } + } + } + end + + describe '#headers' do + it 'normalizes header keys to lowercase' do + expect(request.headers).to eq( + 'host' => 'example.com', + 'user-agent' => 'TestBot/1.0', + 'accept' => 'text/html' + ) + end + + context 'when event has no headers' do + let(:event) { { 'requestContext' => {} } } + + it { expect(request.headers).to eq({}) } + end + end + + describe '#host' do + it { expect(request.host).to eq('example.com') } + end + + describe '#user_agent' do + it { expect(request.user_agent).to eq('TestBot/1.0') } + end + + describe '#remote_addr' do + it { expect(request.remote_addr).to eq('10.0.0.1') } + + context 'when event is API Gateway v2 format' do + let(:event) do + { + 'headers' => {}, + 'requestContext' => { + 'http' => { 'sourceIp' => '10.0.0.2' } + } + } + end + + it { expect(request.remote_addr).to eq('10.0.0.2') } + end + + context 'when event has no requestContext' do + let(:event) { { 'headers' => {} } } + + it { expect(request.remote_addr).to be_nil } + end + end +end diff --git a/test/datadog/lambda/event_source/api_gateway_v1.spec.rb b/test/datadog/lambda/event_source/api_gateway_v1.spec.rb new file mode 100644 index 0000000..35903d2 --- /dev/null +++ b/test/datadog/lambda/event_source/api_gateway_v1.spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'datadog/lambda/event_source/api_gateway_v1' + +RSpec.describe Datadog::Lambda::EventSource::ApiGatewayV1 do + subject(:source) { described_class.new(payload) } + + let(:payload) do + { + 'httpMethod' => 'GET', + 'path' => '/users/42', + 'requestContext' => { + 'stage' => 'prod', + 'domainName' => 'api.example.com', + 'apiId' => 'abc123', + 'resourcePath' => '/users/{id}', + 'requestTimeEpoch' => 1_700_000_000_000, + 'identity' => {'userAgent' => 'TestAgent/1.0'}, + }, + } + end + + describe '.match?' do + it { expect(described_class.match?('not a hash')).to be(false) } + it { expect(described_class.match?({})).to be(false) } + it { expect(described_class.match?('requestContext' => {'stage' => 'prod'})).to be(false) } + it { expect(described_class.match?('httpMethod' => 'GET')).to be(false) } + + it 'matches a v1 proxy integration event' do + expect( + described_class.match?('httpMethod' => 'GET', 'requestContext' => {'stage' => 'prod'}) + ).to be(true) + end + end + + context 'when all fields are present' do + it { expect(source.method).to eq('GET') } + it { expect(source.path).to eq('/users/42') } + it { expect(source.resource_path).to eq('/users/{id}') } + it { expect(source.domain).to eq('api.example.com') } + it { expect(source.api_id).to eq('abc123') } + it { expect(source.stage).to eq('prod') } + it { expect(source.request_time_ms).to eq(1_700_000_000_000) } + it { expect(source.user_agent).to eq('TestAgent/1.0') } + end + + context 'when optional fields are missing' do + let(:payload) { {'httpMethod' => 'POST', 'requestContext' => {'stage' => 'dev'}} } + + it { expect(source.path).to eq('/') } + it { expect(source.resource_path).to eq('/') } + it { expect(source.domain).to be_nil } + it { expect(source.api_id).to be_nil } + it { expect(source.stage).to eq('dev') } + it { expect(source.request_time_ms).to be_nil } + it { expect(source.user_agent).to be_nil } + end +end diff --git a/test/datadog/lambda/event_source/api_gateway_v2.spec.rb b/test/datadog/lambda/event_source/api_gateway_v2.spec.rb new file mode 100644 index 0000000..309cb12 --- /dev/null +++ b/test/datadog/lambda/event_source/api_gateway_v2.spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'datadog/lambda/event_source/api_gateway_v2' + +RSpec.describe Datadog::Lambda::EventSource::ApiGatewayV2 do + subject(:source) { described_class.new(payload) } + + let(:payload) do + { + 'rawPath' => '/users/42', + 'routeKey' => 'GET /users/{id}', + 'requestContext' => { + 'stage' => 'prod', + 'domainName' => 'api.example.com', + 'apiId' => 'xyz789', + 'timeEpoch' => 1_700_000_000_000, + 'http' => {'method' => 'GET', 'userAgent' => 'TestAgent/2.0'}, + }, + } + end + + describe '.match?' do + it { expect(described_class.match?('not a hash')).to be(false) } + it { expect(described_class.match?({})).to be(false) } + it { expect(described_class.match?('requestContext' => {'stage' => 'prod'})).to be(false) } + it { expect(described_class.match?('routeKey' => 'GET /test')).to be(false) } + + it 'matches a v2 proxy integration event' do + expect( + described_class.match?('routeKey' => 'GET /test', 'requestContext' => {'stage' => 'prod'}) + ).to be(true) + end + end + + context 'when all fields are present' do + it { expect(source.method).to eq('GET') } + it { expect(source.path).to eq('/users/42') } + it { expect(source.resource_path).to eq('/users/{id}') } + it { expect(source.domain).to eq('api.example.com') } + it { expect(source.api_id).to eq('xyz789') } + it { expect(source.stage).to eq('prod') } + it { expect(source.request_time_ms).to eq(1_700_000_000_000) } + it { expect(source.user_agent).to eq('TestAgent/2.0') } + end + + context 'when routeKey has no method prefix' do + let(:payload) do + { + 'rawPath' => '/test', + 'routeKey' => '$default', + 'requestContext' => { + 'stage' => 'prod', + 'http' => {'method' => 'GET'}, + }, + } + end + + it { expect(source.resource_path).to eq('$default') } + end + + context 'when optional fields are missing' do + let(:payload) { {'routeKey' => 'POST /data', 'requestContext' => {'stage' => 'dev'}} } + + it { expect(source.path).to eq('/') } + it { expect(source.resource_path).to eq('/data') } + it { expect(source.domain).to be_nil } + it { expect(source.api_id).to be_nil } + it { expect(source.stage).to eq('dev') } + it { expect(source.request_time_ms).to be_nil } + it { expect(source.user_agent).to be_nil } + it { expect(source.method).to be_nil } + end +end diff --git a/test/datadog/lambda/inferred_span.spec.rb b/test/datadog/lambda/inferred_span.spec.rb new file mode 100644 index 0000000..12ed826 --- /dev/null +++ b/test/datadog/lambda/inferred_span.spec.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require 'datadog/lambda' +require 'datadog/lambda/event_source' +require 'datadog/lambda/inferred_span' +require_relative '../lambdacontextversion' + +RSpec.describe Datadog::Lambda::InferredSpan do + let(:request_context) do + instance_double( + LambdaContextVersion, + aws_request_id: 'test-request-id', + invoked_function_arn: 'arn:aws:lambda:us-east-1:123456789:function:test-function' + ) + end + + describe '.try_create' do + subject(:span) { described_class.try_create(event_source, request_context, nil) } + + let(:event_source) { Datadog::Lambda::EventSource.for(event) } + + context 'when event is not a Hash' do + let(:event) { 'not a hash' } + + it { expect(span).to be_nil } + end + + context 'when event has no requestContext' do + let(:event) { {} } + + it { expect(span).to be_nil } + end + + context 'when event has requestContext without stage' do + let(:event) { {'requestContext' => {'apiId' => 'abc'}} } + + it { expect(span).to be_nil } + end + + context 'when event has no httpMethod or routeKey' do + let(:event) { {'requestContext' => {'stage' => 'prod'}} } + + it { expect(span).to be_nil } + end + + context 'when event is API Gateway v1' do + let(:event) do + { + 'httpMethod' => 'GET', + 'path' => '/test', + 'requestContext' => { + 'stage' => 'prod', + 'domainName' => 'api.example.com', + 'apiId' => 'abc123', + 'resourcePath' => '/test', + 'requestTimeEpoch' => 1_700_000_000_000, + 'identity' => {'userAgent' => 'TestAgent/1.0', 'sourceIp' => '1.2.3.4'}, + }, + } + end + + it 'creates a span representing the API Gateway' do + aggregate_failures('span identity') do + expect(span.name).to eq('aws.apigateway') + expect(span.service).to eq('api.example.com') + expect(span.resource).to eq('GET /test') + expect(span.type).to eq('web') + expect(span.start_time).to eq(Time.at(1_700_000_000)) + end + end + + it 'sets tags for endpoint discovery' do + aggregate_failures('http tags') do + expect(span.get_tag('http.method')).to eq('GET') + expect(span.get_tag('http.url')).to eq('https://api.example.com/test') + expect(span.get_tag('http.route')).to eq('/test') + expect(span.get_tag('http.useragent')).to eq('TestAgent/1.0') + expect(span.get_tag('span.kind')).to eq('server') + end + end + + it 'sets tags for API Gateway resource correlation' do + aggregate_failures('gateway tags') do + expect(span.get_tag('apiid')).to eq('abc123') + expect(span.get_tag('stage')).to eq('prod') + expect(span.get_tag('request_id')).to eq('test-request-id') + expect(span.get_tag('dd_resource_key')).to eq( + 'arn:aws:apigateway:us-east-1::/restapis/abc123/stages/prod' + ) + end + end + + it 'marks the span as inferred' do + aggregate_failures('inferred span markers') do + expect(span.get_metric('_dd._inferred_span')).to eq(1.0) + expect(span.get_tag('_inferred_span.synchronicity')).to eq('sync') + expect(span.get_tag('_inferred_span.tag_source')).to eq('self') + end + end + + context 'when trace_digest is provided' do + before { allow(Datadog::Tracing).to receive(:trace).and_return(span_double) } + + let(:span_double) { instance_double(Datadog::Tracing::SpanOperation, set_metric: nil) } + let(:trace_digest) { instance_double(Datadog::Tracing::TraceDigest) } + + subject(:span) { described_class.try_create(event_source, request_context, trace_digest) } + + it 'passes trace_digest as continue_from' do + span + expect(Datadog::Tracing).to have_received(:trace).with( + 'aws.apigateway', hash_including(continue_from: trace_digest) + ) + end + end + + context 'when domainName is missing' do + let(:event) do + { + 'httpMethod' => 'GET', + 'path' => '/test', + 'requestContext' => { + 'stage' => 'prod', + 'apiId' => 'abc123', + 'resourcePath' => '/test', + 'requestTimeEpoch' => 1_700_000_000_000, + }, + } + end + + it { expect(span.get_tag('http.url')).to eq('/test') } + it { expect(span.service).not_to eq('api.example.com') } + end + + context 'when requestTimeEpoch is missing' do + let(:event) do + { + 'httpMethod' => 'GET', + 'path' => '/test', + 'requestContext' => { + 'stage' => 'prod', + 'domainName' => 'api.example.com', + 'apiId' => 'abc123', + 'resourcePath' => '/test', + }, + } + end + + it 'still creates the span' do + expect(span).not_to be_nil + end + end + + context 'when apiId is missing' do + let(:event) do + { + 'httpMethod' => 'GET', + 'path' => '/test', + 'requestContext' => { + 'stage' => 'prod', + 'domainName' => 'api.example.com', + 'resourcePath' => '/test', + }, + } + end + + it 'omits API Gateway resource tags' do + aggregate_failures('tags derived from apiId') do + expect(span).not_to have_tag('apiid') + expect(span).not_to have_tag('apiname') + expect(span).not_to have_tag('dd_resource_key') + end + end + end + end + + context 'when event is API Gateway v2' do + let(:event) do + { + 'rawPath' => '/test', + 'routeKey' => 'GET /test', + 'requestContext' => { + 'stage' => 'prod', + 'domainName' => 'api.example.com', + 'apiId' => 'xyz789', + 'timeEpoch' => 1_700_000_000_000, + 'http' => {'method' => 'GET', 'userAgent' => 'TestAgent/2.0'}, + }, + } + end + + it 'creates a span representing the HTTP API' do + aggregate_failures('span identity') do + expect(span.name).to eq('aws.httpapi') + expect(span.service).to eq('api.example.com') + expect(span.resource).to eq('GET /test') + expect(span.type).to eq('web') + end + end + + it 'sets tags for endpoint discovery' do + aggregate_failures('http tags') do + expect(span.get_tag('http.method')).to eq('GET') + expect(span.get_tag('http.url')).to eq('https://api.example.com/test') + expect(span.get_tag('http.route')).to eq('/test') + expect(span.get_tag('http.useragent')).to eq('TestAgent/2.0') + expect(span.get_tag('span.kind')).to eq('server') + end + end + + it 'sets tags for API Gateway resource correlation' do + aggregate_failures('gateway tags') do + expect(span.get_tag('apiid')).to eq('xyz789') + expect(span.get_tag('stage')).to eq('prod') + expect(span.get_tag('dd_resource_key')).to eq( + 'arn:aws:apigateway:us-east-1::/apis/xyz789/stages/prod' + ) + end + end + + context 'when routeKey has no method prefix' do + let(:event) do + { + 'rawPath' => '/test', + 'routeKey' => '$default', + 'requestContext' => { + 'stage' => 'prod', + 'domainName' => 'api.example.com', + 'apiId' => 'xyz789', + 'timeEpoch' => 1_700_000_000_000, + 'http' => {'method' => 'GET'}, + }, + } + end + + it 'uses routeKey as-is for route' do + aggregate_failures('route and resource') do + expect(span.get_tag('http.route')).to eq('$default') + expect(span.resource).to eq('GET $default') + end + end + end + end + + context 'when an error occurs' do + before { allow(Datadog::Tracing).to receive(:trace).and_raise(StandardError, 'boom') } + + let(:event) do + { + 'httpMethod' => 'GET', + 'path' => '/test', + 'requestContext' => {'stage' => 'prod'}, + } + end + + it { expect(span).to be_nil } + end + end +end diff --git a/test/datadog/lambda/inferred_span/api_gateway_v1.spec.rb b/test/datadog/lambda/inferred_span/api_gateway_v1.spec.rb new file mode 100644 index 0000000..a8ff7a3 --- /dev/null +++ b/test/datadog/lambda/inferred_span/api_gateway_v1.spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'datadog/lambda/inferred_span/api_gateway_v1' +require 'datadog/lambda/event_source/api_gateway_v1' + +RSpec.describe Datadog::Lambda::InferredSpan::ApiGatewayV1 do + subject(:inferred_span) { described_class.new(event_source) } + + let(:event_source) { Datadog::Lambda::EventSource::ApiGatewayV1.new(payload) } + let(:payload) do + { + 'httpMethod' => 'GET', + 'path' => '/users/42', + 'requestContext' => { + 'stage' => 'prod', + 'domainName' => 'api.example.com', + 'apiId' => 'abc123', + 'resourcePath' => '/users/{id}', + 'requestTimeEpoch' => 1_700_000_000_000, + 'identity' => {'userAgent' => 'TestAgent/1.0'}, + }, + } + end + + context 'when all fields are present' do + it { expect(inferred_span.span_name).to eq('aws.apigateway') } + it { expect(inferred_span.method).to eq('GET') } + it { expect(inferred_span.path).to eq('/users/42') } + it { expect(inferred_span.resource_path).to eq('/users/{id}') } + it { expect(inferred_span.domain).to eq('api.example.com') } + it { expect(inferred_span.api_id).to eq('abc123') } + it { expect(inferred_span.stage).to eq('prod') } + it { expect(inferred_span.request_time_ms).to eq(1_700_000_000_000) } + it { expect(inferred_span.user_agent).to eq('TestAgent/1.0') } + it { expect(inferred_span.arn_path_prefix).to eq('restapis') } + end + + context 'when optional fields are missing' do + let(:payload) { {'httpMethod' => 'POST', 'requestContext' => {'stage' => 'dev'}} } + + it { expect(inferred_span.path).to eq('/') } + it { expect(inferred_span.resource_path).to eq('/') } + it { expect(inferred_span.domain).to be_nil } + it { expect(inferred_span.api_id).to be_nil } + it { expect(inferred_span.stage).to eq('dev') } + it { expect(inferred_span.request_time_ms).to be_nil } + it { expect(inferred_span.user_agent).to be_nil } + end +end diff --git a/test/datadog/lambda/inferred_span/api_gateway_v2.spec.rb b/test/datadog/lambda/inferred_span/api_gateway_v2.spec.rb new file mode 100644 index 0000000..205284c --- /dev/null +++ b/test/datadog/lambda/inferred_span/api_gateway_v2.spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'datadog/lambda/inferred_span/api_gateway_v2' +require 'datadog/lambda/event_source/api_gateway_v2' + +RSpec.describe Datadog::Lambda::InferredSpan::ApiGatewayV2 do + subject(:inferred_span) { described_class.new(event_source) } + + let(:event_source) { Datadog::Lambda::EventSource::ApiGatewayV2.new(payload) } + let(:payload) do + { + 'rawPath' => '/users/42', + 'routeKey' => 'GET /users/{id}', + 'requestContext' => { + 'stage' => 'prod', + 'domainName' => 'api.example.com', + 'apiId' => 'xyz789', + 'timeEpoch' => 1_700_000_000_000, + 'http' => {'method' => 'GET', 'userAgent' => 'TestAgent/2.0'}, + }, + } + end + + context 'when all fields are present' do + it { expect(inferred_span.span_name).to eq('aws.httpapi') } + it { expect(inferred_span.method).to eq('GET') } + it { expect(inferred_span.path).to eq('/users/42') } + it { expect(inferred_span.resource_path).to eq('/users/{id}') } + it { expect(inferred_span.domain).to eq('api.example.com') } + it { expect(inferred_span.api_id).to eq('xyz789') } + it { expect(inferred_span.stage).to eq('prod') } + it { expect(inferred_span.request_time_ms).to eq(1_700_000_000_000) } + it { expect(inferred_span.user_agent).to eq('TestAgent/2.0') } + it { expect(inferred_span.arn_path_prefix).to eq('apis') } + end + + context 'when routeKey has no method prefix' do + let(:payload) do + { + 'rawPath' => '/test', + 'routeKey' => '$default', + 'requestContext' => { + 'stage' => 'prod', + 'http' => {'method' => 'GET'}, + }, + } + end + + it { expect(inferred_span.resource_path).to eq('$default') } + end + + context 'when optional fields are missing' do + let(:payload) { {'routeKey' => 'POST /data', 'requestContext' => {'stage' => 'dev'}} } + + it { expect(inferred_span.path).to eq('/') } + it { expect(inferred_span.resource_path).to eq('/data') } + it { expect(inferred_span.domain).to be_nil } + it { expect(inferred_span.api_id).to be_nil } + it { expect(inferred_span.stage).to eq('dev') } + it { expect(inferred_span.request_time_ms).to be_nil } + it { expect(inferred_span.user_agent).to be_nil } + it { expect(inferred_span.method).to be_nil } + end +end diff --git a/test/datadog/lambda/trace/context.spec.rb b/test/datadog/lambda/trace/context.spec.rb index 6e97f2d..a71d456 100644 --- a/test/datadog/lambda/trace/context.spec.rb +++ b/test/datadog/lambda/trace/context.spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -# rubocop:disable Metrics/BlockLength - require 'datadog/lambda/trace/context' require 'datadog/lambda/trace/constants' require 'datadog/lambda/trace/listener' @@ -204,12 +202,15 @@ res = listener.send(:get_option_tags, request_context: ctx, cold_start: false) expect(res).to eq( tags: { + 'span.kind' => 'server', cold_start: false, function_arn: 'arn:aws:lambda:us-east-1:172597598159:function:hello-dog-ruby-dev-hello', request_id: 'dcbfed85-c904-4367-bd54-984ca201ef47', resource_names: "hello-dog-ruby-dev-helloRuby#{RUBY_VERSION[0, 3].tr('.', '')}", functionname: "hello-dog-ruby-dev-helloRuby#{RUBY_VERSION[0, 3].tr('.', '')}".downcase, - function_version: '$LATEST' + function_version: '$LATEST', + datadog_lambda: '3.27.0', + dd_trace: '2.29.0' } ) end @@ -227,12 +228,15 @@ res = listener.send(:get_option_tags, request_context: ctx, cold_start: false) expect(res).to eq( tags: { + 'span.kind' => 'server', cold_start: false, function_arn: 'arn:aws:lambda:us-east-1:172597598159:function:ruby-test', request_id: 'dcbfed85-c904-4367-bd54-984ca201ef47', resource_names: 'Ruby-test', functionname: 'ruby-test', - function_version: '1' + function_version: '1', + datadog_lambda: '3.27.0', + dd_trace: '2.29.0' } ) end @@ -250,16 +254,34 @@ res = listener.send(:get_option_tags, request_context: ctx, cold_start: false) expect(res).to eq( tags: { + 'span.kind' => 'server', cold_start: false, function_arn: 'arn:aws:lambda:us-east-1:172597598159:function:ruby-test', request_id: 'dcbfed85-c904-4367-bd54-984ca201ef47', resource_names: 'Ruby-test', functionname: 'ruby-test', - function_version: 'my-alias' + function_version: 'my-alias', + datadog_lambda: '3.27.0', + dd_trace: '2.29.0' } ) end end -end -# rubocop:enable Metrics/BlockLength + context 'when datadog gem is unavailable' do + before { allow(Datadog::Utils).to receive(:dd_trace_version).and_return(nil) } + + let(:result) { listener.send(:get_option_tags, request_context: ctx, cold_start: false) } + let(:ctx) { LambdaContext.new } + let(:listener) do + Datadog::Trace::Listener.new( + handler_name: 'foo', function_name: 'bar', + patch_http: true, merge_xray_traces: false + ) + end + + it 'excludes dd_trace from option tags' do + expect(result[:tags]).not_to have_key(:dd_trace) + end + end +end diff --git a/test/datadog/lambda/trace/listener.spec.rb b/test/datadog/lambda/trace/listener.spec.rb new file mode 100644 index 0000000..6c62b20 --- /dev/null +++ b/test/datadog/lambda/trace/listener.spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'datadog/lambda/trace/listener' +require_relative '../../lambdacontext' + +RSpec.describe Datadog::Trace::Listener do + describe '#on_start' do + before do + allow(Datadog::Utils).to receive(:send_start_invocation_request).and_return(nil) + allow(Datadog::Tracing).to receive(:trace).and_return( + instance_double(Datadog::Tracing::SpanOperation) + ) + end + + let(:request_context) { LambdaContext.new } + let(:on_start) do + listener.on_start(event: {}, request_context: request_context, cold_start: false) + end + + context 'when DD_SERVICE is configured' do + before { allow(Datadog.configuration).to receive(:service).and_return('my-custom-service') } + + let(:listener) do + described_class.new( + handler_name: 'handler', function_name: 'my-function', + patch_http: false, merge_xray_traces: false + ) + end + + it 'uses DD_SERVICE for service and function name for resource' do + on_start + expect(Datadog::Tracing).to have_received(:trace).with( + 'aws.lambda', + hash_including(service: 'my-custom-service', resource: 'my-function') + ) + end + end + + context 'when DD_SERVICE is not configured' do + before { allow(Datadog.configuration).to receive(:service).and_return(nil) } + + let(:listener) do + described_class.new( + handler_name: 'handler', function_name: 'my-function', + patch_http: false, merge_xray_traces: false + ) + end + + it 'uses function name for both service and resource' do + on_start + expect(Datadog::Tracing).to have_received(:trace).with( + 'aws.lambda', + hash_including(service: 'my-function', resource: 'my-function') + ) + end + end + + context 'when function_name is nil' do + before { allow(Datadog.configuration).to receive(:service).and_return(nil) } + + let(:listener) do + described_class.new( + handler_name: 'handler', function_name: nil, + patch_http: false, merge_xray_traces: false + ) + end + + it 'falls back to aws.lambda for both service and resource' do + on_start + expect(Datadog::Tracing).to have_received(:trace).with( + 'aws.lambda', + hash_including(service: 'aws.lambda', resource: 'aws.lambda') + ) + end + end + end + + describe '#on_end' do + before do + allow(Datadog::Utils).to receive(:send_start_invocation_request).and_return(nil) + allow(Datadog::Utils).to receive(:send_end_invocation_request) + allow(Datadog::Tracing).to receive(:trace).and_return(span) + end + + let(:listener) do + described_class.new( + handler_name: 'handler', function_name: 'my-function', + patch_http: false, merge_xray_traces: false + ) + end + let(:request_context) { LambdaContext.new } + let(:span) { instance_double(Datadog::Tracing::SpanOperation, id: 123, set_tag: nil, finish: nil) } + + context 'when response contains statusCode' do + before { listener.on_start(event: {}, request_context: request_context, cold_start: false) } + + it 'sets http.status_code on the lambda span' do + listener.on_end(response: { statusCode: 200 }, request_context: request_context) + expect(span).to have_received(:set_tag).with('http.status_code', 200) + end + end + + context 'when response is not a Hash' do + before { listener.on_start(event: {}, request_context: request_context, cold_start: false) } + + it 'does not set http.status_code' do + listener.on_end(response: nil, request_context: request_context) + expect(span).not_to have_received(:set_tag) + end + end + + context 'when inferred span exists' do + before do + allow(Datadog::Lambda::InferredSpan).to receive(:try_create).and_return(inferred_span) + listener.on_start(event: {}, request_context: request_context, cold_start: false) + end + + let(:inferred_span) { instance_double(Datadog::Tracing::SpanOperation, finish: nil, set_tag: nil) } + + it 'sets http.status_code on both spans' do + listener.on_end(response: { statusCode: 200 }, request_context: request_context) + aggregate_failures 'status code on both spans' do + expect(span).to have_received(:set_tag).with('http.status_code', 200) + expect(inferred_span).to have_received(:set_tag).with('http.status_code', 200) + end + end + end + end +end diff --git a/test/datadog/lambda/utils/version.spec.rb b/test/datadog/lambda/utils/version.spec.rb new file mode 100644 index 0000000..9ee201c --- /dev/null +++ b/test/datadog/lambda/utils/version.spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'datadog/lambda/utils/version' + +RSpec.describe Datadog::Utils, '.dd_trace_version' do + subject(:version) { described_class.dd_trace_version } + + context 'when datadog gem is loaded' do + it { expect(version).to match(/\A\d+\.\d+\.\d+/) } + end + + context 'when datadog gem is not available' do + before do + allow(Gem).to receive(:loaded_specs).and_raise(StandardError) + allow(Datadog::Utils.logger).to receive(:debug) + end + + it { expect(version).to be_nil } + + it 'logs a debug message' do + version + expect(Datadog::Utils.logger).to have_received(:debug).with('datadog unavailable') + end + end +end