Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions lib/ldclient-otel/tracing_hook.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
134 changes: 112 additions & 22 deletions spec/tracing_hook_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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