diff --git a/lib/datadog/opentelemetry/sdk/span_processor.rb b/lib/datadog/opentelemetry/sdk/span_processor.rb index 1860c3a193f..655facab164 100644 --- a/lib/datadog/opentelemetry/sdk/span_processor.rb +++ b/lib/datadog/opentelemetry/sdk/span_processor.rb @@ -115,7 +115,7 @@ def span_arguments(span, attributes) # DEV: There's no `flat_map!`; we have to split it into two operations attributes = attributes.map do |key, value| - Datadog::OpenTelemetry::Trace::Span.serialize_attribute(key, value) + Datadog::Tracing::Utils.serialize_attribute(key, value) end attributes.flatten!(1) diff --git a/lib/datadog/opentelemetry/sdk/trace/span.rb b/lib/datadog/opentelemetry/sdk/trace/span.rb index e6ccd7f964c..6bf30657193 100644 --- a/lib/datadog/opentelemetry/sdk/trace/span.rb +++ b/lib/datadog/opentelemetry/sdk/trace/span.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'datadog/tracing/utils' + module Datadog module OpenTelemetry module Trace @@ -36,22 +38,6 @@ def status=(s) span.set_error(status.description) if status && status.code == ::OpenTelemetry::Trace::Status::ERROR end - # Serialize values into Datadog span tags and metrics. - # Notably, arrays are exploded into many keys, each with - # a numeric suffix representing the array index, for example: - # `'foo' => ['a','b']` becomes `'foo.0' => 'a', 'foo.1' => 'b'` - def self.serialize_attribute(key, value) - if value.is_a?(Array) - value.flat_map.with_index do |v, idx| - serialize_attribute("#{key}.#{idx}", v) - end - elsif value.is_a?(TrueClass) || value.is_a?(FalseClass) - [[key, value.to_s]] - else - [[key, value]] - end - end - # Create a meaningful Datadog operation name from the OpenTelemetry # semantic convention for span kind and span attributes. # @see https://opentelemetry.io/docs/specs/semconv/general/trace/ @@ -123,7 +109,7 @@ def datadog_set_attribute(key) span.name = rich_name.downcase end - Span.serialize_attribute(key, @attributes[key]).each do |new_key, value| + Tracing::Utils.serialize_attribute(key, @attributes[key]).each do |new_key, value| override_datadog_values(span, new_key, value) # When an attribute is used to override a Datadog Span property, diff --git a/lib/datadog/tracing/span.rb b/lib/datadog/tracing/span.rb index 018b50e6690..57f156dcef1 100644 --- a/lib/datadog/tracing/span.rb +++ b/lib/datadog/tracing/span.rb @@ -26,6 +26,7 @@ class Span :parent_id, :resource, :service, + :links, :type, :start_time, :status, @@ -58,7 +59,8 @@ def initialize( status: 0, type: nil, trace_id: nil, - service_entry: nil + service_entry: nil, + links: nil ) @name = Core::Utils::SafeDup.frozen_or_dup(name) @service = Core::Utils::SafeDup.frozen_or_dup(service) @@ -86,6 +88,8 @@ def initialize( @service_entry = service_entry + @links = links || [] + # Mark with the service entry span metric, if applicable set_metric(Metadata::Ext::TAG_TOP_LEVEL, 1.0) if service_entry end @@ -136,7 +140,8 @@ def to_hash service: @service, span_id: @id, trace_id: @trace_id, - type: @type + type: @type, + span_links: @links.map(&:to_hash) } if stopped? diff --git a/lib/datadog/tracing/span_link.rb b/lib/datadog/tracing/span_link.rb new file mode 100644 index 00000000000..8194b2e1c46 --- /dev/null +++ b/lib/datadog/tracing/span_link.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Datadog + module Tracing + # SpanLink represents a causal link between two spans. + # @public_api + class SpanLink + # @!attribute [r] span_id + # Datadog id for the currently active span. + # @return [Integer] + attr_reader :span_id + + # @!attribute [r] trace_id + # Datadog id for the currently active trace. + # @return [Integer] + attr_reader :trace_id + + # @!attribute [r] attributes + # Datadog-specific tags that support richer distributed tracing association. + # @return [Hash] + attr_reader :attributes + + # @!attribute [r] trace_flags + # The W3C "trace-flags" extracted from a distributed context. This field is an 8-bit unsigned integer. + # @return [Integer] + # @see https://www.w3.org/TR/trace-context/#trace-flags + attr_reader :trace_flags + + # @!attribute [r] trace_state + # The W3C "tracestate" extracted from a distributed context. + # This field is a string representing vendor-specific distribution data. + # The `dd=` entry is removed from `trace_state` as its value is dynamically calculated + # on every propagation injection. + # @return [String] + # @see https://www.w3.org/TR/trace-context/#tracestate-header + attr_reader :trace_state + + # @!attribute [r] dropped_attributes + # The number of attributes that were discarded due to serialization limits. + # @return [Integer] + attr_reader :dropped_attributes + + def initialize( + attributes: nil, + digest: nil + ) + @span_id = digest&.span_id + @trace_id = digest&.trace_id + @trace_flags = digest&.trace_flags + @trace_state = digest&.trace_state && digest&.trace_state.dup + @dropped_attributes = 0 + @attributes = (attributes && attributes.dup) || {} + end + + def to_hash + h = { + span_id: @span_id || 0, + trace_id: Tracing::Utils::TraceId.to_low_order(@trace_id) || 0, + } + # Optimization: Hash non empty attributes + if @trace_id.to_i > Tracing::Utils::EXTERNAL_MAX_ID + h[:trace_id_high] = + Tracing::Utils::TraceId.to_high_order(@trace_id) + end + unless @attributes&.empty? + h[:attributes] = {} + @attributes.each do |k1, v1| + Tracing::Utils.serialize_attribute(k1, v1).each do |new_k1, value| + h[:attributes][new_k1] = value.to_s + end + end + end + h[:dropped_attributes_count] = @dropped_attributes if @dropped_attributes > 0 + h[:tracestate] = @trace_state if @trace_state + # If traceflags set, the high bit (bit 31) should be set to 1 (uint32). + # This helps us distinguish between when the sample decision is zero or not set + h[:flags] = if @trace_flags.nil? + 0 + else + @trace_flags | (1 << 31) + end + h + end + end + end +end diff --git a/lib/datadog/tracing/transport/serializable_trace.rb b/lib/datadog/tracing/transport/serializable_trace.rb index e14ce2c7b27..b056af71c2a 100644 --- a/lib/datadog/tracing/transport/serializable_trace.rb +++ b/lib/datadog/tracing/transport/serializable_trace.rb @@ -58,7 +58,7 @@ def initialize(span) def to_msgpack(packer = nil) packer ||= MessagePack::Packer.new - number_of_elements_to_write = 10 + number_of_elements_to_write = 11 if span.stopped? packer.write_map_header(number_of_elements_to_write + 2) # Set header with how many elements in the map @@ -93,6 +93,8 @@ def to_msgpack(packer = nil) packer.write(span.meta) packer.write('metrics') packer.write(span.metrics) + packer.write('span_links') + packer.write(span.links.map(&:to_hash)) packer.write('error') packer.write(span.status) packer diff --git a/lib/datadog/tracing/utils.rb b/lib/datadog/tracing/utils.rb index 543b2986654..a0001f5679d 100644 --- a/lib/datadog/tracing/utils.rb +++ b/lib/datadog/tracing/utils.rb @@ -45,6 +45,22 @@ def self.reset! @id_rng = Random.new end + # Serialize values into Datadog span tags and metrics. + # Notably, arrays are exploded into many keys, each with + # a numeric suffix representing the array index, for example: + # `'foo' => ['a','b']` becomes `'foo.0' => 'a', 'foo.1' => 'b'` + def self.serialize_attribute(key, value) + if value.is_a?(Array) + value.flat_map.with_index do |v, idx| + serialize_attribute("#{key}.#{idx}", v) + end + elsif value.is_a?(TrueClass) || value.is_a?(FalseClass) + [[key, value.to_s]] + else + [[key, value]] + end + end + private_class_method :id_rng, :reset! # The module handles bitwise operation for trace id diff --git a/sig/datadog/tracing/span_link.rbs b/sig/datadog/tracing/span_link.rbs new file mode 100644 index 00000000000..e2dcc49aef1 --- /dev/null +++ b/sig/datadog/tracing/span_link.rbs @@ -0,0 +1,58 @@ +module Datadog + module Tracing + # SpanLink represents a causal link between two spans. + # @public_api + class SpanLink + @span_id: untyped + + @trace_id: untyped + + @trace_flags: untyped + + @trace_state: untyped + + @dropped_attributes: untyped + + @attributes: untyped + + # @!attribute [r] span_id + # Datadog id for the currently active span. + # @return [Integer] + attr_reader span_id: untyped + + # @!attribute [r] trace_id + # Datadog id for the currently active trace. + # @return [Integer] + attr_reader trace_id: untyped + + # @!attribute [r] attributes + # Datadog-specific tags that support richer distributed tracing association. + # @return [Hash] + attr_reader attributes: untyped + + # @!attribute [r] trace_flags + # The W3C "trace-flags" extracted from a distributed context. This field is an 8-bit unsigned integer. + # @return [Integer] + # @see https://www.w3.org/TR/trace-context/#trace-flags + attr_reader trace_flags: untyped + + # @!attribute [r] trace_state + # The W3C "tracestate" extracted from a distributed context. + # This field is a string representing vendor-specific distribution data. + # The `dd=` entry is removed from `trace_state` as its value is dynamically calculated + # on every propagation injection. + # @return [String] + # @see https://www.w3.org/TR/trace-context/#tracestate-header + attr_reader trace_state: untyped + + # @!attribute [r] dropped_attributes + # The number of attributes that were discarded due to serialization limits. + # @return [Integer] + attr_reader dropped_attributes: untyped + + def initialize: (?attributes: untyped?, ?digest: untyped?) -> void + + def to_hash: () -> untyped + end + end +end diff --git a/sig/datadog/tracing/utils.rbs b/sig/datadog/tracing/utils.rbs index b20ac2dbc97..589493e4180 100644 --- a/sig/datadog/tracing/utils.rbs +++ b/sig/datadog/tracing/utils.rbs @@ -10,6 +10,9 @@ module Datadog def self.id_rng: () -> untyped def self.reset!: () -> untyped + + def self.serialize_attribute: (untyped key, untyped value) -> (untyped | ::Array[::Array[untyped]]) + module TraceId MAX: untyped def self?.next_id: () -> untyped @@ -22,4 +25,4 @@ module Datadog end end end -end +end \ No newline at end of file diff --git a/spec/datadog/tracing/span_link_spec.rb b/spec/datadog/tracing/span_link_spec.rb new file mode 100644 index 00000000000..d9e1dcdce8d --- /dev/null +++ b/spec/datadog/tracing/span_link_spec.rb @@ -0,0 +1,123 @@ +require 'spec_helper' +require 'support/object_helpers' + +require 'datadog/tracing/span_link' +require 'datadog/tracing/trace_digest' + +RSpec.describe Datadog::Tracing::SpanLink do + subject(:span_link) { described_class.new(attributes: attributes, digest: digest) } + + let(:attributes) { nil } + let(:digest) do + Datadog::Tracing::TraceDigest.new( + span_id: span_id, + trace_id: trace_id, + trace_flags: trace_flags, + trace_state: trace_state, + ) + end + let(:span_id) { nil } + let(:trace_id) { nil } + let(:trace_state) { nil } + let(:trace_flags) { nil } + + describe '::new' do + context 'by default' do + let(:digest) { nil } + let(:attributes) { nil } + + it do + is_expected.to have_attributes( + span_id: nil, + attributes: {}, + trace_id: nil, + trace_flags: nil, + trace_state: nil, + ) + end + end + + context 'given' do + context ':attributes' do + let(:attributes) { { tag: 'value' } } + it { is_expected.to have_attributes(attributes: attributes) } + end + + context ':digest with' do + context ':span_id' do + let(:span_id) { Datadog::Tracing::Utils.next_id } + it { is_expected.to have_attributes(span_id: span_id) } + end + + context ':trace_id' do + let(:trace_id) { Datadog::Tracing::Utils::TraceId.next_id } + it { is_expected.to have_attributes(trace_id: trace_id) } + end + + context ':trace_flags' do + let(:trace_flags) { 0x01 } + it { is_expected.to have_attributes(trace_flags: 0x01) } + end + + context ':trace_state' do + let(:trace_state) { 'vendor1=value,v2=v' } + it { is_expected.to have_attributes(trace_state: 'vendor1=value,v2=v') } + end + end + end + end + + describe '#to_hash' do + subject(:to_hash) { span_link.to_hash } + let(:span_id) { 34 } + let(:trace_id) { 12 } + + context 'with required fields' do + context 'when trace_id < 2^64' do + it { is_expected.to eq(trace_id: 12, span_id: 34, flags: 0) } + end + + context 'when trace_id >= 2^64' do + let(:trace_id) { 2**64 + 12 } + it { is_expected.to eq(trace_id: 12, trace_id_high: 1, span_id: 34, flags: 0) } + end + end + + context 'when required fields are not set' do + let(:span_id) { nil } + let(:trace_id) { nil } + it { is_expected.to eq(trace_id: 0, span_id: 0, flags: 0) } + end + + context 'when trace_state is set' do + let(:trace_state) { 'dd=s:1' } + it { is_expected.to include(tracestate: 'dd=s:1') } + end + + context 'when trace_flag is set' do + context 'when trace_flag is unset' do + it { is_expected.to include(flags: 0) } + end + + context 'when trace_flags is 0' do + let(:trace_flags) { 0 } + it { is_expected.to include(flags: 2147483648) } + end + + context 'when trace_flag is 1' do + let(:trace_flags) { 1 } + it { is_expected.to include(flags: 2147483649) } + end + end + + context 'when attributes is set' do + let(:attributes) { { 'link.name' => :test_link, 'link.id' => 1, 'nested' => [true, [2, 3], 'val'] } } + it { + is_expected.to include( + attributes: { 'link.name' => 'test_link', 'link.id' => '1', 'nested.0' => 'true', + 'nested.1.0' => '2', 'nested.1.1' => '3', 'nested.2' => 'val', } + ) + } + end + end +end diff --git a/spec/datadog/tracing/span_spec.rb b/spec/datadog/tracing/span_spec.rb index 22b33812dfb..0c6bca7f3f0 100644 --- a/spec/datadog/tracing/span_spec.rb +++ b/spec/datadog/tracing/span_spec.rb @@ -244,6 +244,7 @@ type: nil, meta: {}, metrics: {}, + span_links: [], error: 0 ) end diff --git a/spec/datadog/tracing/transport/serializable_trace_spec.rb b/spec/datadog/tracing/transport/serializable_trace_spec.rb index 52136cfd932..e558f24770f 100644 --- a/spec/datadog/tracing/transport/serializable_trace_spec.rb +++ b/spec/datadog/tracing/transport/serializable_trace_spec.rb @@ -70,6 +70,64 @@ end end end + + context 'when given span links' do + subject(:unpacked_trace) { MessagePack.unpack(to_msgpack) } + + let(:spans) do + Array.new(3) do |_i| + Datadog::Tracing::Span.new( + 'dummy', + links: [ + Datadog::Tracing::SpanLink.new( + digest: Datadog::Tracing::TraceDigest.new( + trace_id: 0xaaaaaaaaaaaaaaaaffffffffffffffff, + span_id: 0x1, + trace_state: 'vendor1=value,v2=v,dd=s:1', + trace_flags: 0x1, + ), + attributes: { 'link.name' => 'test_link' } + ), + Datadog::Tracing::SpanLink.new( + digest: Datadog::Tracing::TraceDigest.new( + trace_id: 0xa0123456789abcdef, + span_id: 0x2, + ), + ), + Datadog::Tracing::SpanLink.new( + digest: Datadog::Tracing::TraceDigest.new, + ) + ], + ) + end + end + + it 'serializes span links' do + expect( + unpacked_trace.map do |s| + s['span_links'] + end + ).to all( + eq( + [{ + 'span_id' => 1, + 'trace_id' => 0xffffffffffffffff, + 'trace_id_high' => 0xaaaaaaaaaaaaaaaa, + 'attributes' => { 'link.name' => 'test_link' }, + 'flags' => 2147483649, + 'tracestate' => 'vendor1=value,v2=v,dd=s:1', + }, + { + 'span_id' => 2, + 'trace_id' => 0x0123456789abcdef, + 'trace_id_high' => 10, + 'flags' => 0 + }, + { 'span_id' => 0, 'trace_id' => 0, 'flags' => 0 }] + ) + ) + end + end end describe '#to_json' do