diff --git a/lib/datadog/appsec/actions_handler.rb b/lib/datadog/appsec/actions_handler.rb index 75376de1748..e0c0d34915d 100644 --- a/lib/datadog/appsec/actions_handler.rb +++ b/lib/datadog/appsec/actions_handler.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require_relative '../tracing/stack_trace/collector' +require_relative '../tracing/stack_trace/representor' + module Datadog module AppSec # this module encapsulates functions for handling actions that libddawf returns @@ -19,7 +22,41 @@ def interrupt_execution(action_params) throw(Datadog::AppSec::Ext::INTERRUPT, action_params) end - def generate_stack(_action_params); end + def generate_stack(action_params) + if Datadog.configuration.appsec.stack_trace.enabled + context = AppSec::Context.active + # We use methods defined in Tracing::Metadata::Tagging, + # which means we can use both the trace and the service entry span + service_entry_operation = (context.trace || context.span) if context + + if service_entry_operation.nil? + Datadog.logger.debug { 'Cannot find trace or service entry span to add stack trace' } + return + end + + # caller_locations without params always returns an array but steep still thinks it can be nil + # So we add || [] but it will never run the second part anyway (either this or steep:ignore) + stack_frames = caller_locations || [] + # Steep thinks that path can still be nil and that include? is not a method of nil + # We must add a variable assignment to avoid this + stack_frames.reject! do |loc| + path = loc.path + next true if path.nil? + + path.include?('lib/datadog') + end + + collected_stack_frames = Datadog::Tracing::StackTrace::Collector.collect(stack_frames) + utf8_stack_id = action_params['stack_id'].encode('UTF-8') if action_params['stack_id'] + stack_trace = Datadog::Tracing::StackTrace::Representor.new( + id: utf8_stack_id, + message: nil, + frames: collected_stack_frames + ) + + service_entry_operation.set_stack_trace(stack_trace, group: AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY) + end + end def generate_schema(_action_params); end end diff --git a/lib/datadog/appsec/configuration/settings.rb b/lib/datadog/appsec/configuration/settings.rb index 74a3aabb62e..e9b668ca436 100644 --- a/lib/datadog/appsec/configuration/settings.rb +++ b/lib/datadog/appsec/configuration/settings.rb @@ -214,6 +214,55 @@ def self.add_settings!(base) o.default false end end + + settings :stack_trace do + option :enabled do |o| + o.type :bool + o.env 'DD_APPSEC_STACK_TRACE_ENABLED' + o.default true + end + + # The maximum number of stack frames to collect for each stack trace. + # If the number of frames in a stack trace exceeds this value, + # max_depth / 4 frames will be collected from the top, and max_depth * 3 / 4 from the bottom. + option :max_depth do |o| + o.type :int + o.env 'DD_APPSEC_MAX_STACK_TRACE_DEPTH' + o.default 32 + # 0 means no limit + o.setter do |value| + value = 0 if value.negative? + value + end + end + + # The percentage that decides the number of top stack frame to collect + # for each stack trace if there is more stack frames than max_depth. + # number_of_top_frames = max_depth * max_depth_top_percent / 100 + # Default is 75 + option :max_depth_top_percent do |o| + o.type :float + o.env 'DD_APPSEC_MAX_STACK_TRACE_DEPTH_TOP_PERCENT' + o.default 75 + o.setter do |value| + value = 100 if value > 100 + value = 0 if value < 0 + value + end + end + + # The maximum number of stack traces to collect for each exploit prevention event. + option :max_collect do |o| + o.type :int + o.env 'DD_APPSEC_MAX_STACK_TRACES' + o.default 2 + # 0 means no limit + o.setter do |value| + value = 0 if value < 0 + value + end + end + end end end end diff --git a/lib/datadog/appsec/ext.rb b/lib/datadog/appsec/ext.rb index 6c76e708ad1..745789acb90 100644 --- a/lib/datadog/appsec/ext.rb +++ b/lib/datadog/appsec/ext.rb @@ -10,12 +10,14 @@ module Ext INTERRUPT = :datadog_appsec_interrupt CONTEXT_KEY = 'datadog.appsec.context' ACTIVE_CONTEXT_KEY = :datadog_appsec_active_context + EXPLOIT_PREVENTION_EVENT_CATEGORY = 'exploit' TAG_APPSEC_ENABLED = '_dd.appsec.enabled' TAG_APM_ENABLED = '_dd.apm.enabled' TAG_DISTRIBUTED_APPSEC_EVENT = '_dd.p.appsec' TELEMETRY_METRICS_NAMESPACE = 'appsec' + TAG_STACK_TRACE = '_dd.stack' end end end diff --git a/lib/datadog/tracing/metadata/ext.rb b/lib/datadog/tracing/metadata/ext.rb index 95cf3d4e7f0..3c213991e9b 100644 --- a/lib/datadog/tracing/metadata/ext.rb +++ b/lib/datadog/tracing/metadata/ext.rb @@ -193,6 +193,10 @@ module SpanKind TAG_INTERNAL = 'internal' end + module MetaStruct + TAG_STACK_TRACE = '_dd.stack' + end + # @public_api end end diff --git a/lib/datadog/tracing/metadata/meta_struct.rb b/lib/datadog/tracing/metadata/meta_struct.rb index 25823e6fa0b..2dbdd375aff 100644 --- a/lib/datadog/tracing/metadata/meta_struct.rb +++ b/lib/datadog/tracing/metadata/meta_struct.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true +require_relative 'stack_trace' + module Datadog module Tracing module Metadata # Adds complex structures tagging behavior through meta_struct # @public_api module MetaStruct + include StackTrace + def set_meta_struct(meta_struct) self.meta_struct.merge!(meta_struct) end diff --git a/lib/datadog/tracing/metadata/stack_trace.rb b/lib/datadog/tracing/metadata/stack_trace.rb new file mode 100644 index 00000000000..5ddb378af8f --- /dev/null +++ b/lib/datadog/tracing/metadata/stack_trace.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Datadog + module Tracing + module Metadata + # Adds stack traces to meta_struct + # @public_api + module StackTrace + def set_stack_trace(stack_trace, group:) + meta_struct[Metadata::Ext::MetaStruct::TAG_STACK_TRACE] ||= {} + meta_struct[Metadata::Ext::MetaStruct::TAG_STACK_TRACE][group] ||= [] + + stack_trace_group = meta_struct[Metadata::Ext::MetaStruct::TAG_STACK_TRACE][group] + max_collect = Datadog.configuration.appsec.stack_trace.max_collect + return if max_collect > 0 && stack_trace_group.size >= max_collect + + stack_trace_group << stack_trace + rescue StandardError => e + Datadog.logger.debug("Unable to add stack_trace #{stack_trace.id} in meta_struct, ignoring it. Caused by: #{e}") + end + end + end + end +end diff --git a/lib/datadog/tracing/stack_trace/collector.rb b/lib/datadog/tracing/stack_trace/collector.rb new file mode 100644 index 00000000000..ac2f2e0897c --- /dev/null +++ b/lib/datadog/tracing/stack_trace/collector.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative 'frame' + +module Datadog + module Tracing + module StackTrace + # Represent a stack trace with its id and message in message pack + module Collector + class << self + def collect(locations) + return [] if locations.nil? || locations.empty? + + skip_frames = skip_frames(locations.size) + frames = [] + + locations.each_with_index do |location, index| + next if skip_frames.include?(index) + + frames << StackTrace::Frame.new( + id: index, + text: location.to_s.encode('UTF-8'), + file: file_path(location), + line: location.lineno, + function: function_label(location) + ) + end + frames + end + + private + + def skip_frames(locations_size) + max_depth = Datadog.configuration.appsec.stack_trace.max_depth + return [] if max_depth == 0 || locations_size <= max_depth + + top_frames_limit = (max_depth * Datadog.configuration.appsec.stack_trace.max_depth_top_percent / 100.0).round + bottom_frames_limit = locations_size - (max_depth - top_frames_limit) + (top_frames_limit...bottom_frames_limit) + end + + def file_path(location) + path = location.absolute_path || location.path + return if path.nil? + + path.encode('UTF-8') + end + + def function_label(location) + label = location.label + return if label.nil? + + label.encode('UTF-8') + end + end + end + end + end +end diff --git a/lib/datadog/tracing/stack_trace/frame.rb b/lib/datadog/tracing/stack_trace/frame.rb new file mode 100644 index 00000000000..49867e1e60f --- /dev/null +++ b/lib/datadog/tracing/stack_trace/frame.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Datadog + module Tracing + module StackTrace + # Formatted stack frame. + # This class extends a Struct as it's required by Steep to be able to add a method to it. + class Frame < Struct.new(:id, :text, :file, :line, :function, keyword_init: true) # rubocop:disable Style/StructInheritance + def to_msgpack(packer = nil) + packer ||= MessagePack::Packer.new + + packer.write_map_header(5) + packer.write('id') + packer.write(id) + packer.write('text') + packer.write(text) + packer.write('file') + packer.write(file) + packer.write('line') + packer.write(line) + packer.write('function') + packer.write(function) + packer + end + end + end + end +end diff --git a/lib/datadog/tracing/stack_trace/representor.rb b/lib/datadog/tracing/stack_trace/representor.rb new file mode 100644 index 00000000000..1ff151ec4ea --- /dev/null +++ b/lib/datadog/tracing/stack_trace/representor.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Datadog + module Tracing + module StackTrace + # Represent a stack trace with its id and message in message pack + class Representor < Struct.new(:id, :message, :frames, keyword_init: true) # rubocop:disable Style/StructInheritance + def to_msgpack(packer = nil) + packer ||= MessagePack::Packer.new + + packer.write_map_header(4) + packer.write('language') + packer.write('ruby') + packer.write('id') + packer.write(id) + packer.write('message') + packer.write(message) + packer.write('frames') + packer.write(frames) + packer + end + end + end + end +end diff --git a/sig/datadog/appsec/ext.rbs b/sig/datadog/appsec/ext.rbs index 58bb2277da3..78bf0fad212 100644 --- a/sig/datadog/appsec/ext.rbs +++ b/sig/datadog/appsec/ext.rbs @@ -14,6 +14,7 @@ module Datadog CONTEXT_KEY: ::String ACTIVE_CONTEXT_KEY: ::Symbol + EXPLOIT_PREVENTION_EVENT_CATEGORY: ::String TAG_APPSEC_ENABLED: ::String @@ -22,6 +23,7 @@ module Datadog TAG_DISTRIBUTED_APPSEC_EVENT: ::String TELEMETRY_METRICS_NAMESPACE: ::String + TAG_STACK_TRACE: ::String end end end diff --git a/sig/datadog/tracing/metadata/ext.rbs b/sig/datadog/tracing/metadata/ext.rbs index 9820be2b123..ad1c212d9b4 100644 --- a/sig/datadog/tracing/metadata/ext.rbs +++ b/sig/datadog/tracing/metadata/ext.rbs @@ -99,6 +99,10 @@ module Datadog TAG_CONSUMER: ::String TAG_INTERNAL: ::String end + + module MetaStruct + TAG_STACK_TRACE: ::String + end end end end diff --git a/sig/datadog/tracing/metadata/meta_struct.rbs b/sig/datadog/tracing/metadata/meta_struct.rbs index 895ee7a66fe..138c29ded7d 100644 --- a/sig/datadog/tracing/metadata/meta_struct.rbs +++ b/sig/datadog/tracing/metadata/meta_struct.rbs @@ -2,6 +2,8 @@ module Datadog module Tracing module Metadata module MetaStruct + include StackTrace + def set_meta_struct: (Hash[String, untyped] meta_struct) -> void def meta_struct: () -> Hash[String, untyped] diff --git a/sig/datadog/tracing/metadata/stack_trace.rbs b/sig/datadog/tracing/metadata/stack_trace.rbs new file mode 100644 index 00000000000..da0715d9792 --- /dev/null +++ b/sig/datadog/tracing/metadata/stack_trace.rbs @@ -0,0 +1,14 @@ +module Datadog + module Tracing + module Metadata + module StackTrace + def set_stack_trace: (Datadog::Tracing::StackTrace::Representor stack_trace, group: ::String) -> void + + private + + # Class/Module that includes this module should implement this method + def meta_struct: () -> Hash[String, untyped] + end + end + end +end diff --git a/sig/datadog/tracing/stack_trace/collector.rbs b/sig/datadog/tracing/stack_trace/collector.rbs new file mode 100644 index 00000000000..35564ab45f2 --- /dev/null +++ b/sig/datadog/tracing/stack_trace/collector.rbs @@ -0,0 +1,17 @@ +module Datadog + module Tracing + module StackTrace + module Collector + def self.collect: (Array[Thread::Backtrace::Location] locations) -> Array[Datadog::Tracing::StackTrace::Frame] + + private + + def self.skip_frames: (Integer locations_size) -> (Range[Integer] | Array[untyped]) + + def self.file_path: (Thread::Backtrace::Location location) -> String? + + def self.function_label: (Thread::Backtrace::Location location) -> String? + end + end + end +end diff --git a/sig/datadog/tracing/stack_trace/frame.rbs b/sig/datadog/tracing/stack_trace/frame.rbs new file mode 100644 index 00000000000..eea1e2d48ed --- /dev/null +++ b/sig/datadog/tracing/stack_trace/frame.rbs @@ -0,0 +1,17 @@ +module Datadog + module Tracing + module StackTrace + class Frame + attr_reader id: Integer + attr_reader text: String? + attr_reader file: String? + attr_reader line: Integer? + attr_reader function: String? + + def initialize: (?id: Integer, ?text: String?, ?file: String?, ?line: Integer?, ?function: String?) -> void + + def to_msgpack: ((::MessagePack::Packer | nil) packer) -> ::MessagePack::Packer + end + end + end +end diff --git a/sig/datadog/tracing/stack_trace/representor.rbs b/sig/datadog/tracing/stack_trace/representor.rbs new file mode 100644 index 00000000000..9a3f8153bea --- /dev/null +++ b/sig/datadog/tracing/stack_trace/representor.rbs @@ -0,0 +1,15 @@ +module Datadog + module Tracing + module StackTrace + class Representor + attr_reader id: String? + attr_reader message: String? + attr_reader frames: Array[StackTrace::Frame]? + + def initialize: (?id: String?, ?message: String?, ?frames: Array[StackTrace::Frame]?) -> void + + def to_msgpack: ((::MessagePack::Packer | nil) packer) -> ::MessagePack::Packer + end + end + end +end diff --git a/spec/datadog/appsec/configuration/settings_spec.rb b/spec/datadog/appsec/configuration/settings_spec.rb index 541546ae364..87f10c792f8 100644 --- a/spec/datadog/appsec/configuration/settings_spec.rb +++ b/spec/datadog/appsec/configuration/settings_spec.rb @@ -819,5 +819,189 @@ def patcher end end end + + describe 'stack_trace' do + describe '#enabled' do + subject(:enabled) { settings.appsec.stack_trace.enabled } + + context 'when DD_APPSEC_STACK_TRACE_ENABLED' do + around do |example| + ClimateControl.modify('DD_APPSEC_STACK_TRACE_ENABLED' => stack_trace_enabled) do + example.run + end + end + + context 'is not defined' do + let(:stack_trace_enabled) { nil } + + it { is_expected.to eq true } + end + + [true, false].each do |value| + context "is defined as #{value}" do + let(:stack_trace_enabled) { value.to_s } + + it { is_expected.to eq value } + end + end + end + end + + describe '#enabled=' do + subject(:set_stack_trace_enabled) { settings.appsec.stack_trace.enabled = stack_trace_enabled } + + [true, false].each do |value| + context "when given #{value}" do + let(:stack_trace_enabled) { value } + + before { set_stack_trace_enabled } + + it { expect(settings.appsec.stack_trace.enabled).to eq(value) } + end + end + end + + describe '#max_depth' do + subject(:max_depth) { settings.appsec.stack_trace.max_depth } + + context 'when DD_APPSEC_MAX_STACK_TRACE_DEPTH' do + around do |example| + ClimateControl.modify('DD_APPSEC_MAX_STACK_TRACE_DEPTH' => stack_trace_max_depth) do + example.run + end + end + + context 'is not defined' do + let(:stack_trace_max_depth) { nil } + + it { is_expected.to eq 32 } + end + + context 'is defined' do + let(:stack_trace_max_depth) { '64' } + + it { is_expected.to eq(64) } + end + end + end + + describe '#max_depth=' do + subject(:set_stack_trace_max_depth) { settings.appsec.stack_trace.max_depth = stack_trace_max_depth } + + context 'when given a value' do + let(:stack_trace_max_depth) { 64 } + + before { set_stack_trace_max_depth } + + it { expect(settings.appsec.stack_trace.max_depth).to eq(64) } + end + + context 'when given a negative value' do + let(:stack_trace_max_depth) { -1 } + + before { set_stack_trace_max_depth } + + it { expect(settings.appsec.stack_trace.max_depth).to eq(0) } + end + end + + describe '#max_depth_top_percent' do + subject(:max_depth_top_percent) { settings.appsec.stack_trace.max_depth_top_percent } + + context 'when DD_APPSEC_MAX_STACK_TRACE_DEPTH_TOP_PERCENT' do + around do |example| + ClimateControl.modify('DD_APPSEC_MAX_STACK_TRACE_DEPTH_TOP_PERCENT' => stack_trace_max_depth_top_percent) do + example.run + end + end + + context 'is not defined' do + let(:stack_trace_max_depth_top_percent) { nil } + + it { is_expected.to eq 75 } + end + + context 'is defined' do + let(:stack_trace_max_depth_top_percent) { '50' } + + it { is_expected.to eq(50) } + end + end + end + + describe '#max_depth_top_percent=' do + subject(:set_stack_trace_max_depth_top_percent) do + settings.appsec.stack_trace.max_depth_top_percent = stack_trace_max_depth_top_percent + end + + context 'when given a value' do + let(:stack_trace_max_depth_top_percent) { 50 } + + before { set_stack_trace_max_depth_top_percent } + + it { expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(50) } + end + + context 'when given a negative value' do + let(:stack_trace_max_depth_top_percent) { -1 } + + before { set_stack_trace_max_depth_top_percent } + + it { expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(0) } + end + + context 'when given a value higher than 100' do + let(:stack_trace_max_depth_top_percent) { 101 } + + before { set_stack_trace_max_depth_top_percent } + + it { expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(100) } + end + end + + describe '#max_collect' do + subject(:max_collect) { settings.appsec.stack_trace.max_collect } + + context 'when DD_APPSEC_MAX_STACK_TRACES' do + around do |example| + ClimateControl.modify('DD_APPSEC_MAX_STACK_TRACES' => stack_trace_max_collect) do + example.run + end + end + + context 'is not defined' do + let(:stack_trace_max_collect) { nil } + + it { is_expected.to eq 2 } + end + + context 'is defined' do + let(:stack_trace_max_collect) { '4' } + + it { is_expected.to eq(4) } + end + end + end + + describe '#max_collect=' do + subject(:set_stack_trace_max_collect) { settings.appsec.stack_trace.max_collect = stack_trace_max_collect } + + context 'when given a value' do + let(:stack_trace_max_collect) { 4 } + + before { set_stack_trace_max_collect } + + it { expect(settings.appsec.stack_trace.max_collect).to eq(4) } + end + + context 'when given a negative value' do + let(:stack_trace_max_collect) { -1 } + + before { set_stack_trace_max_collect } + + it { expect(settings.appsec.stack_trace.max_collect).to eq(0) } + end + end + end end end diff --git a/spec/datadog/tracing/stack_trace/collector_spec.rb b/spec/datadog/tracing/stack_trace/collector_spec.rb new file mode 100644 index 00000000000..bb209de0fcf --- /dev/null +++ b/spec/datadog/tracing/stack_trace/collector_spec.rb @@ -0,0 +1,118 @@ +require 'datadog/tracing/stack_trace/collector' +require 'support/thread_backtrace_helpers' + +RSpec.describe Datadog::Tracing::StackTrace::Collector do + subject(:collection) { described_class.collect(frames) } + + before do + Datadog.configure do |c| + c.appsec.stack_trace.max_depth = max_depth if max_depth + c.appsec.stack_trace.max_depth_top_percent = max_depth_top_percent if max_depth_top_percent + end + end + + # "/app/spec/support/thread_backtrace_helpers.rb:12:in `block in locations_inside_nested_blocks'", + # "/app/spec/support/thread_backtrace_helpers.rb:14:in `block (2 levels) in locations_inside_nested_blocks'", + # "/app/spec/support/thread_backtrace_helpers.rb:16:in `block (3 levels) in locations_inside_nested_blocks'", + # "/app/spec/support/thread_backtrace_helpers.rb:16:in `block (4 levels) in locations_inside_nested_blocks'", + # "/app/spec/support/thread_backtrace_helpers.rb:16:in `block (5 levels) in locations_inside_nested_blocks'", + let(:frames) { ThreadBacktraceHelper.locations_inside_nested_blocks } + + # This should use default values + let(:max_depth) { nil } + let(:max_depth_top_percent) { nil } + + describe '::collect' do + context 'with default values' do + it 'creates a stack trace with default values' do + expect(collection.count).to eq(5) + end + end + + context 'without values' do + let(:frames) { nil } + + it 'does not cause an error' do + expect { collection }.to_not raise_error + end + end + + context 'with max_depth set to 4' do + let(:max_depth) { 4 } + + it 'creates a stack trace with 4 frames, 3 top' do + expect(collection.count).to eq(4) + expect(collection[2].text).to eq(frames[2].to_s) + expect(collection[3].text).to eq(frames[4].to_s) + end + + context 'with max_depth_top_percent set to 25' do + let(:max_depth_top_percent) { 25 } + + it 'creates a stack trace with 4 frames, 1 top' do + expect(collection.count).to eq(4) + expect(collection[0].text).to eq(frames[0].to_s) + expect(collection[1].text).to eq(frames[2].to_s) + end + end + + context 'with max_depth_top_percent set to 100' do + let(:max_depth_top_percent) { 100 } + + it 'creates a stack trace with 4 top frames' do + expect(collection.count).to eq(4) + expect(collection[0].text).to eq(frames[0].to_s) + expect(collection[3].text).to eq(frames[3].to_s) + end + end + + context 'with max_depth_top_percent set to 0' do + let(:max_depth_top_percent) { 0 } + + it 'creates a stack trace with 4 bottom frames' do + expect(collection.count).to eq(4) + expect(collection[0].text).to eq(frames[1].to_s) + expect(collection[3].text).to eq(frames[4].to_s) + end + end + end + + context 'with max_depth set to 3' do + let(:max_depth) { 3 } + + context 'with max_depth_top_percent set to 66.67' do + let(:max_depth_top_percent) { 200 / 3.0 } + + it 'creates a stack trace with 3 frames, 2 top' do + expect(collection.count).to eq(3) + expect(collection[1].text).to eq(frames[1].to_s) + expect(collection[2].text).to eq(frames[4].to_s) + end + end + end + + context 'with max_depth set to 0' do + let(:max_depth) { 0 } + + it 'does not apply any limit' do + expect(collection.count).to eq(5) + end + end + + context 'with values encoded in ASCII-8BIT' do + let(:frames) { ThreadBacktraceHelper.location_ascii_8bit } + + it 'creates a stack trace with correctly encoded values' do + expect(collection.count).to eq(1) + expect(collection[0].id).to eq(0) + expect(collection[0].text).to eq(frames[0].to_s) + expect(collection[0].text.encoding).to eq(Encoding::UTF_8) + expect(collection[0].file).to eq(frames[0].path) + expect(collection[0].file.encoding).to eq(Encoding::UTF_8) + expect(collection[0].line).to eq(frames[0].lineno) + expect(collection[0].function).to eq(frames[0].label) + expect(collection[0].function.encoding).to eq(Encoding::UTF_8) + end + end + end +end diff --git a/spec/datadog/tracing/stack_trace/frame_spec.rb b/spec/datadog/tracing/stack_trace/frame_spec.rb new file mode 100644 index 00000000000..1730be415af --- /dev/null +++ b/spec/datadog/tracing/stack_trace/frame_spec.rb @@ -0,0 +1,32 @@ +require 'datadog/tracing/stack_trace/frame' + +RSpec.describe Datadog::Tracing::StackTrace::Frame do + subject(:dd_frame) { described_class.new(id: id, text: text, file: file, line: line, function: function) } + + let(:id) { 0 } + let(:text) { "/app/spec/support/thread_backtrace_helpers.rb:14:in `block (2 levels) in locations_inside_nested_blocks'" } + let(:file) { '/app/spec/support/thread_backtrace_helpers.rb' } + let(:line) { 14 } + let(:function) { 'block (2 levels) in locations_inside_nested_blocks' } + + describe '::new' do + it 'creates a frame with correct values' do + expect(dd_frame.id).to eq(id) + expect(dd_frame.text).to eq(text) + expect(dd_frame.file).to eq(file) + expect(dd_frame.line).to eq(line) + expect(dd_frame.function).to eq(function) + end + end + + describe '#to_msgpack' do + it 'returns a MessagePack object with correct values' do + frame_msgpack = dd_frame.to_msgpack + + expect(frame_msgpack).to be_a(MessagePack::Packer) + # As message pack converts keys to string, + # we must also convert the keys of the hash to string in this test + expect(MessagePack.unpack(frame_msgpack.to_s)).to eq(dd_frame.to_h.transform_keys(&:to_s)) + end + end +end diff --git a/spec/datadog/tracing/stack_trace/representor_spec.rb b/spec/datadog/tracing/stack_trace/representor_spec.rb new file mode 100644 index 00000000000..7859e65307c --- /dev/null +++ b/spec/datadog/tracing/stack_trace/representor_spec.rb @@ -0,0 +1,33 @@ +require 'datadog/tracing/stack_trace/representor' +require 'datadog/tracing/stack_trace/collector' +require 'support/thread_backtrace_helpers' + +RSpec.describe Datadog::Tracing::StackTrace::Representor do + subject(:dd_stack_trace) { described_class.new(id: id, message: message, frames: frames) } + + let(:id) { 'stack_id' } + let(:frames) { Datadog::Tracing::StackTrace::Collector.collect(ThreadBacktraceHelper.locations_inside_nested_blocks) } + let(:message) { 'Test message' } + + describe '::new' do + it 'creates a stack trace representation with correct values' do + expect(dd_stack_trace.id).to eq(id) + expect(dd_stack_trace.frames).to eq(frames) + expect(dd_stack_trace.message).to eq(message) + end + end + + describe '#to_msgpack' do + it 'returns a MessagePack object with correct values' do + stack_trace_msgpack = dd_stack_trace.to_msgpack + + expect(stack_trace_msgpack).to be_a(MessagePack::Packer) + + result = dd_stack_trace.to_h.transform_keys(&:to_s) + result['frames'] = result['frames'].map { |f| f.to_h.transform_keys(&:to_s) } + result['language'] = 'ruby' + + expect(MessagePack.unpack(stack_trace_msgpack.to_s)).to eq(result) + end + end +end diff --git a/spec/support/thread_backtrace_helpers.rb b/spec/support/thread_backtrace_helpers.rb new file mode 100644 index 00000000000..5da2082e53d --- /dev/null +++ b/spec/support/thread_backtrace_helpers.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# Module to test stack trace generation. Inspired by: +# https://github.com/ruby/ruby/blob/master/spec/ruby/core/thread/backtrace/location/fixtures/classes.rb +module ThreadBacktraceHelper + def self.locations + caller_locations + end + + # Deeply nested blocks to test max_depth and max_depth_top_percentage variables + def self.locations_inside_nested_blocks + first_level_location = nil + second_level_location = nil + third_level_location = nil + fourth_level_location = nil + fifth_level_location = nil + + # rubocop:disable Lint/UselessTimes + 1.times do + first_level_location = locations.first + 1.times do + second_level_location = locations.first + 1.times do + third_level_location = locations.first + 1.times do + fourth_level_location = locations.first + 1.times do + fifth_level_location = locations.first + end + end + end + end + end + # rubocop:enable Lint/UselessTimes + + [first_level_location, second_level_location, third_level_location, fourth_level_location, fifth_level_location] + end + + LocationASCII8Bit = Struct.new(:text, :path, :lineno, :label, keyword_init: true) do + def to_s + text + end + end + + def self.location_ascii_8bit + location = locations.first + LocationASCII8Bit.new( + text: location.to_s.encode('ASCII-8BIT'), + path: (location.absolute_path || location.path).encode('ASCII-8BIT'), + lineno: location.lineno, + label: location.label.encode('ASCII-8BIT') + ) + + [location] + end +end