diff --git a/lib/ldclient-otel/tracing_hook.rb b/lib/ldclient-otel/tracing_hook.rb index e5a65aa..1baeec7 100644 --- a/lib/ldclient-otel/tracing_hook.rb +++ b/lib/ldclient-otel/tracing_hook.rb @@ -23,8 +23,22 @@ class TracingHookOptions # # @return [Boolean] # + attr_reader :include_value + + # + # Deprecated: Use include_value instead. + # + # @return [Boolean] + # attr_reader :include_variant + # + # Optional environment ID to include as feature_flag.set.id attribute. + # + # @return [String, nil] + # + attr_reader :environment_id + # # The logger used for hook execution. Provide a custom logger or use the default which logs to the console. # @@ -37,13 +51,25 @@ class TracingHookOptions # # @param opts [Hash] the configuration options # @option opts [Boolean, nil] :add_spans See {#add_spans}. - # @option opts [Boolean] :include_variant See {#include_variant}. + # @option opts [Boolean] :include_value See {#include_value}. + # @option opts [Boolean] :include_variant (Deprecated) See {#include_variant}. + # @option opts [String, nil] :environment_id See {#environment_id}. # @option opts [Logger] :logger See {#logger}. # def initialize(opts = {}) @add_spans = opts.fetch(:add_spans, nil) + @include_value = opts.fetch(:include_value, opts.fetch(:include_variant, false)) @include_variant = opts.fetch(:include_variant, false) @logger = opts[:logger] || LaunchDarkly::Otel.default_logger + @environment_id = validate_environment_id(opts[:environment_id]) + end + + private def validate_environment_id(env_id) + return nil if env_id.nil? + return env_id if env_id.is_a?(String) && !env_id.empty? + + @logger.warn("LaunchDarkly Tracing Hook: Invalid environment_id provided. It must be a non-empty string.") + nil end end @@ -81,7 +107,7 @@ def before_evaluation(evaluation_series_context, data) return data unless @config.add_spans attributes = { - 'feature_flag.context.key' => evaluation_series_context.context.fully_qualified_key, + 'feature_flag.context.id' => evaluation_series_context.context.fully_qualified_key, 'feature_flag.key' => evaluation_series_context.key, } span = @tracer.start_span(evaluation_series_context.method, attributes: attributes) @@ -114,10 +140,17 @@ def after_evaluation(evaluation_series_context, data, detail) event = { 'feature_flag.key' => evaluation_series_context.key, - 'feature_flag.provider_name' => 'LaunchDarkly', - 'feature_flag.context.key' => evaluation_series_context.context.fully_qualified_key, + 'feature_flag.provider.name' => 'LaunchDarkly', + 'feature_flag.context.id' => evaluation_series_context.context.fully_qualified_key, } - event['feature_flag.variant'] = detail.value.to_s if @config.include_variant + + event['feature_flag.result.value'] = detail.value.to_s if @config.include_value || @config.include_variant + + event['feature_flag.result.reason.inExperiment'] = true if detail.reason&.in_experiment + + event['feature_flag.result.variationIndex'] = detail.variation_index if detail.variation_index + + event['feature_flag.set.id'] = @config.environment_id if @config.environment_id span.add_event('feature_flag', attributes: event) diff --git a/spec/tracing_hook_spec.rb b/spec/tracing_hook_spec.rb index c61a6cd..63e52c2 100644 --- a/spec/tracing_hook_spec.rb +++ b/spec/tracing_hook_spec.rb @@ -40,14 +40,14 @@ event = spans[0].events[0] expect(event.name).to eq 'feature_flag' expect(event.attributes['feature_flag.key']).to eq 'boolean' - expect(event.attributes['feature_flag.provider_name']).to eq 'LaunchDarkly' - expect(event.attributes['feature_flag.context.key']).to eq 'org:org-key' - expect(event.attributes['feature_flag.variant']).to be_nil + expect(event.attributes['feature_flag.provider.name']).to eq 'LaunchDarkly' + expect(event.attributes['feature_flag.context.id']).to eq 'org:org-key' + expect(event.attributes['feature_flag.result.value']).to be_nil end end - context 'with include_variant' do - let(:options) { LaunchDarkly::Otel::TracingHookOptions.new({include_variant: true}) } + context 'with include_value' do + let(:options) { LaunchDarkly::Otel::TracingHookOptions.new({include_value: true}) } let(:hook) { LaunchDarkly::Otel::TracingHook.new(options) } let(:config) { LaunchDarkly::Config.new({data_source: td, hooks: [hook]}) } let(:client) { LaunchDarkly::LDClient.new('key', config) } @@ -64,9 +64,29 @@ event = spans[0].events[0] expect(event.name).to eq 'feature_flag' expect(event.attributes['feature_flag.key']).to eq 'boolean' - expect(event.attributes['feature_flag.provider_name']).to eq 'LaunchDarkly' - expect(event.attributes['feature_flag.context.key']).to eq 'org:org-key' - expect(event.attributes['feature_flag.variant']).to eq 'true' + expect(event.attributes['feature_flag.provider.name']).to eq 'LaunchDarkly' + expect(event.attributes['feature_flag.context.id']).to eq 'org:org-key' + expect(event.attributes['feature_flag.result.value']).to eq 'true' + end + end + + context 'with include_variant (deprecated)' do + let(:options) { LaunchDarkly::Otel::TracingHookOptions.new({include_variant: true}) } + let(:hook) { LaunchDarkly::Otel::TracingHook.new(options) } + let(:config) { LaunchDarkly::Config.new({data_source: td, hooks: [hook]}) } + let(:client) { LaunchDarkly::LDClient.new('key', config) } + + it 'still works for backward compatibility' do + flag = LaunchDarkly::Integrations::TestData::FlagBuilder.new('boolean').boolean_flag + td.update(flag) + + tracer.in_span('toplevel') do |span| + result = client.variation('boolean', {key: 'org-key', kind: 'org'}, false) + end + + spans = exporter.finished_spans + event = spans[0].events[0] + expect(event.attributes['feature_flag.result.value']).to eq 'true' end end @@ -82,7 +102,7 @@ spans = exporter.finished_spans expect(spans.count).to eq 1 - expect(spans[0].attributes['feature_flag.context.key']).to eq 'org:org-key' + expect(spans[0].attributes['feature_flag.context.id']).to eq 'org:org-key' expect(spans[0].attributes['feature_flag.key']).to eq 'boolean' expect(spans[0].events).to be_nil end @@ -101,19 +121,18 @@ ld_span = spans[0] toplevel = spans[1] - expect(ld_span.attributes['feature_flag.context.key']).to eq 'org:org-key' + expect(ld_span.attributes['feature_flag.context.id']).to eq 'org:org-key' expect(ld_span.attributes['feature_flag.key']).to eq 'boolean' event = toplevel.events[0] expect(event.name).to eq 'feature_flag' expect(event.attributes['feature_flag.key']).to eq 'boolean' - expect(event.attributes['feature_flag.provider_name']).to eq 'LaunchDarkly' - expect(event.attributes['feature_flag.context.key']).to eq 'org:org-key' - expect(event.attributes['feature_flag.variant']).to be_nil + expect(event.attributes['feature_flag.provider.name']).to eq 'LaunchDarkly' + expect(event.attributes['feature_flag.context.id']).to eq 'org:org-key' + expect(event.attributes['feature_flag.result.value']).to be_nil end it 'hook makes its span active' do - # By adding the same hook twice, we should get 3 spans. client.add_hook(LaunchDarkly::Otel::TracingHook.new(options)) flag = LaunchDarkly::Integrations::TestData::FlagBuilder.new('boolean').boolean_flag @@ -130,23 +149,94 @@ middle = spans[1] top = spans[2] - expect(inner.attributes['feature_flag.context.key']).to eq 'org:org-key' + expect(inner.attributes['feature_flag.context.id']).to eq 'org:org-key' expect(inner.attributes['feature_flag.key']).to eq 'boolean' expect(inner.events).to be_nil - expect(middle.attributes['feature_flag.context.key']).to eq 'org:org-key' + expect(middle.attributes['feature_flag.context.id']).to eq 'org:org-key' expect(middle.attributes['feature_flag.key']).to eq 'boolean' expect(middle.events[0].name).to eq 'feature_flag' expect(middle.events[0].attributes['feature_flag.key']).to eq 'boolean' - expect(middle.events[0].attributes['feature_flag.provider_name']).to eq 'LaunchDarkly' - expect(middle.events[0].attributes['feature_flag.context.key']).to eq 'org:org-key' - expect(middle.events[0].attributes['feature_flag.variant']).to be_nil + expect(middle.events[0].attributes['feature_flag.provider.name']).to eq 'LaunchDarkly' + expect(middle.events[0].attributes['feature_flag.context.id']).to eq 'org:org-key' + expect(middle.events[0].attributes['feature_flag.result.value']).to be_nil expect(top.events[0].name).to eq 'feature_flag' expect(top.events[0].attributes['feature_flag.key']).to eq 'boolean' - expect(top.events[0].attributes['feature_flag.provider_name']).to eq 'LaunchDarkly' - expect(top.events[0].attributes['feature_flag.context.key']).to eq 'org:org-key' - expect(top.events[0].attributes['feature_flag.variant']).to be_nil + expect(top.events[0].attributes['feature_flag.provider.name']).to eq 'LaunchDarkly' + expect(top.events[0].attributes['feature_flag.context.id']).to eq 'org:org-key' + expect(top.events[0].attributes['feature_flag.result.value']).to be_nil end + + context 'with environment_id' do + let(:options) { LaunchDarkly::Otel::TracingHookOptions.new({environment_id: 'test-env-123'}) } + let(:hook) { LaunchDarkly::Otel::TracingHook.new(options) } + let(:config) { LaunchDarkly::Config.new({data_source: td, hooks: [hook]}) } + let(:client) { LaunchDarkly::LDClient.new('key', config) } + + it 'includes environment_id in event' do + tracer.in_span('toplevel') do |span| + result = client.variation('boolean', {key: 'org-key', kind: 'org'}, true) + end + + spans = exporter.finished_spans + event = spans[0].events[0] + expect(event.attributes['feature_flag.set.id']).to eq 'test-env-123' + end + + it 'does not include environment_id when invalid' do + invalid_options = LaunchDarkly::Otel::TracingHookOptions.new({environment_id: ''}) + invalid_hook = LaunchDarkly::Otel::TracingHook.new(invalid_options) + invalid_config = LaunchDarkly::Config.new({data_source: td, hooks: [invalid_hook]}) + invalid_client = LaunchDarkly::LDClient.new('key', invalid_config) + + tracer.in_span('toplevel') do |span| + result = invalid_client.variation('boolean', {key: 'org-key', kind: 'org'}, true) + end + + spans = exporter.finished_spans + event = spans[0].events[0] + expect(event.attributes['feature_flag.set.id']).to be_nil + end + end + + context 'with inExperiment and variationIndex' do + let(:hook) { LaunchDarkly::Otel::TracingHook.new } + let(:config) { LaunchDarkly::Config.new({data_source: td, hooks: [hook]}) } + let(:client) { LaunchDarkly::LDClient.new('key', config) } + + it 'includes inExperiment when evaluation is part of experiment' do + flag = LaunchDarkly::Integrations::TestData::FlagBuilder.new('experiment-flag') + .variations(false, true) + .fallthrough_variation(1) + .on(true) + td.update(flag) + + tracer.in_span('toplevel') do |span| + result = client.variation('experiment-flag', {key: 'user-key', kind: 'user'}, false) + end + + spans = exporter.finished_spans + event = spans[0].events[0] + expect(event.attributes.key?('feature_flag.result.reason.inExperiment')).to be false + end + + it 'includes variationIndex when available' do + flag = LaunchDarkly::Integrations::TestData::FlagBuilder.new('indexed-flag') + .variations('value-0', 'value-1', 'value-2') + .fallthrough_variation(1) + .on(true) + td.update(flag) + + tracer.in_span('toplevel') do |span| + result = client.variation('indexed-flag', {key: 'user-key', kind: 'user'}, 'default') + end + + spans = exporter.finished_spans + event = spans[0].events[0] + expect(event.attributes['feature_flag.result.variationIndex']).to eq 1 + end + end + end end