Skip to content

Commit 6d698ab

Browse files
committed
feat: allow for propagation_style in Sidekiq instrumentation to have dynamic option
This still permits static values (and environment variable to configure them) but allows users to opt-in to having a callable/dynamic value for the given option. Resolves #991
1 parent 2ceaf46 commit 6d698ab

File tree

6 files changed

+181
-26
lines changed

6 files changed

+181
-26
lines changed

instrumentation/base/lib/opentelemetry/instrumentation/base.rb

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
#
55
# SPDX-License-Identifier: Apache-2.0
66

7+
require 'opentelemetry/instrumentation/dynamic_validator'
8+
79
module OpenTelemetry
810
module Instrumentation
911
# The Base class holds all metadata and configuration for an
@@ -142,22 +144,17 @@ def compatible(&blk)
142144
# and a validation callable to be provided.
143145
# @param [String] name The name of the configuration option
144146
# @param default The default value to be used, or to used if validation fails
145-
# @param [Callable, Symbol] validate Accepts a callable or a symbol that matches
146-
# a key in the VALIDATORS hash. The supported keys are, :array, :boolean,
147-
# :callable, :integer, :string.
147+
# @param [Callable, Symbol] validate Accepts a callable, Enumerable,
148+
# DynamicValidator, or a symbol that matches a key in the VALIDATORS
149+
# hash. The supported keys are, :array, :boolean, :callable, :integer,
150+
# :string.
148151
def option(name, default:, validate:)
149152
validator = VALIDATORS[validate] || validate
150-
raise ArgumentError, "validate must be #{VALIDATORS.keys.join(', ')}, or a callable" unless validator.respond_to?(:call) || validator.respond_to?(:include?)
153+
raise ArgumentError, "validate must be #{VALIDATORS.keys.join(', ')}, or a callable" unless validator.respond_to?(:call) || validator.respond_to?(:include?) || validator.is_a?(DynamicValidator)
151154

152155
@options ||= []
153156

154-
validation_type = if VALIDATORS[validate]
155-
validate
156-
elsif validate.respond_to?(:include?)
157-
:enum
158-
else
159-
:callable
160-
end
157+
validation_type = get_validation_type(validator)
161158

162159
@options << { name: name, default: default, validator: validator, validation_type: validation_type }
163160
end
@@ -187,6 +184,16 @@ def infer_version
187184
rescue NameError
188185
nil
189186
end
187+
188+
def get_validation_type(validation)
189+
validation_type = if VALIDATORS[validation] || validation.is_a?(DynamicValidator)
190+
validation
191+
elsif validation.respond_to?(:include?)
192+
:enum
193+
else
194+
:callable
195+
end
196+
end
190197
end
191198

192199
attr_reader :name, :version, :config, :installed, :tracer
@@ -277,17 +284,25 @@ def config_options(user_config)
277284
option_name = option[:name]
278285
config_value = user_config[option_name]
279286
config_override = coerce_env_var(config_overrides[option_name], option[:validation_type]) if config_overrides[option_name]
287+
static_validator =
288+
if option[:validator].is_a?(DynamicValidator)
289+
option[:validator].static_validation
290+
else
291+
option[:validator]
292+
end
280293

281294
# rubocop:disable Lint/DuplicateBranch
282295
value = if config_value.nil? && config_override.nil?
283296
option[:default]
284-
elsif option[:validator].respond_to?(:include?) && option[:validator].include?(config_override)
297+
elsif static_validator.respond_to?(:include?) && static_validator.include?(config_override)
285298
config_override
286-
elsif option[:validator].respond_to?(:include?) && option[:validator].include?(config_value)
299+
elsif static_validator.respond_to?(:include?) && static_validator.include?(config_value)
287300
config_value
288-
elsif option[:validator].respond_to?(:call) && option[:validator].call(config_override)
301+
elsif static_validator.respond_to?(:call) && static_validator.call(config_override)
289302
config_override
290-
elsif option[:validator].respond_to?(:call) && option[:validator].call(config_value)
303+
elsif static_validator.respond_to?(:call) && static_validator.call(config_value)
304+
config_value
305+
elsif option[:validator].is_a?(DynamicValidator) && config_value.respond_to?(:call)
291306
config_value
292307
else
293308
OpenTelemetry.logger.warn(
@@ -378,6 +393,8 @@ def coerce_env_var(env_var, validation_type)
378393
"configurable using environment variables. Ignoring raw value: #{env_var}"
379394
)
380395
nil
396+
when DynamicValidator
397+
coerce_env_var(env_var, get_validation_type(validation_type.static_validation))
381398
end
382399
end
383400
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
8+
module OpenTelemetry
9+
module Instrumentation
10+
# Can wrap a static validation in this class to allow users to
11+
# alternatively invoke a callable.
12+
class DynamicValidator
13+
def initialize(static_validation)
14+
raise ArgumentError, 'static_validation cannot be dynamic' if static_validation.is_a?(self.class)
15+
@static_validation = static_validation
16+
end
17+
18+
attr_reader :static_validation
19+
end
20+
end
21+
end

instrumentation/base/test/instrumentation/base_test.rb

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ def initialize(*args)
200200
option(:third, default: 1, validate: ->(v) { v <= 10 })
201201
option(:forth, default: false, validate: :boolean)
202202
option(:fifth, default: true, validate: :boolean)
203+
option(:sixth, default: :cool, validate: OpenTelemetry::Instrumentation::DynamicValidator.new(%i[cool beans man]))
203204
end
204205
end
205206

@@ -208,42 +209,42 @@ def initialize(*args)
208209
it 'installs options defined by environment variable and overrides defaults' do
209210
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENV_CONTROLLED_CONFIG_OPTS' => 'first=non_default_value') do
210211
instance.install
211-
_(instance.config).must_equal(first: 'non_default_value', second: :no, third: 1, forth: false, fifth: true)
212+
_(instance.config).must_equal(first: 'non_default_value', second: :no, third: 1, forth: false, fifth: true, sixth: :cool)
212213
end
213214
end
214215

215216
it 'installs boolean type options defined by environment variable and only evalutes the lowercase string "true" to be truthy' do
216217
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENV_CONTROLLED_CONFIG_OPTS' => 'first=non_default_value;forth=true;fifth=truthy') do
217218
instance.install
218-
_(instance.config).must_equal(first: 'non_default_value', second: :no, third: 1, forth: true, fifth: false)
219+
_(instance.config).must_equal(first: 'non_default_value', second: :no, third: 1, forth: true, fifth: false, sixth: :cool)
219220
end
220221
end
221222

222223
it 'installs only enum options defined by environment variable that accept a symbol' do
223224
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENV_CONTROLLED_CONFIG_OPTS' => 'second=maybe') do
224225
instance.install
225-
_(instance.config).must_equal(first: 'first_default', second: :maybe, third: 1, forth: false, fifth: true)
226+
_(instance.config).must_equal(first: 'first_default', second: :maybe, third: 1, forth: false, fifth: true, sixth: :cool)
226227
end
227228
end
228229

229230
it 'installs options defined by environment variable and overrides local configuration' do
230231
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENV_CONTROLLED_CONFIG_OPTS' => 'first=non_default_value') do
231232
instance.install(first: 'another_default')
232-
_(instance.config).must_equal(first: 'non_default_value', second: :no, third: 1, forth: false, fifth: true)
233+
_(instance.config).must_equal(first: 'non_default_value', second: :no, third: 1, forth: false, fifth: true, sixth: :cool)
233234
end
234235
end
235236

236237
it 'installs multiple options defined by environment variable' do
237-
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENV_CONTROLLED_CONFIG_OPTS' => 'first=non_default_value;second=maybe') do
238+
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENV_CONTROLLED_CONFIG_OPTS' => 'first=non_default_value;second=maybe;sixth:beans') do
238239
instance.install(first: 'another_default', second: :yes)
239-
_(instance.config).must_equal(first: 'non_default_value', second: :maybe, third: 1, forth: false, fifth: true)
240+
_(instance.config).must_equal(first: 'non_default_value', second: :maybe, third: 1, forth: false, fifth: true, sixth: :beans)
240241
end
241242
end
242243

243244
it 'does not install callable options defined by environment variable' do
244-
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENV_CONTROLLED_CONFIG_OPTS' => 'first=non_default_value;second=maybe;third=5') do
245+
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENV_CONTROLLED_CONFIG_OPTS' => 'first=non_default_value;second=maybe;third=5;sixth:beans') do
245246
instance.install(first: 'another_default', second: :yes)
246-
_(instance.config).must_equal(first: 'non_default_value', second: :maybe, third: 1, forth: false, fifth: true)
247+
_(instance.config).must_equal(first: 'non_default_value', second: :maybe, third: 1, forth: false, fifth: true, sixth: :beans)
247248
end
248249
end
249250
end
@@ -317,6 +318,56 @@ def initialize(*args)
317318
end
318319
end
319320
end
321+
322+
describe 'when there is an option with a DynamicValidator (wrapping an enum) validation type' do
323+
after do
324+
# Force re-install of instrumentation
325+
instance.instance_variable_set(:@installed, false)
326+
end
327+
328+
let(:enum_instrumentation) do
329+
Class.new(OpenTelemetry::Instrumentation::Base) do
330+
instrumentation_name 'opentelemetry_instrumentation_enum'
331+
instrumentation_version '0.0.2'
332+
333+
present { true }
334+
compatible { true }
335+
install { true }
336+
337+
option(:first, default: :no, validate: OpenTelemetry::Instrumentation::DynamicValidator.new(%I[yes no maybe]))
338+
option(:second, default: :no, validate: OpenTelemetry::Instrumentation::DynamicValidator.new(%I[yes no maybe]))
339+
end
340+
end
341+
342+
let(:instance) { enum_instrumentation.instance }
343+
344+
it 'falls back to the default if user option is not an enumerable option' do
345+
instance.install(first: :yes, second: :perhaps)
346+
_(instance.config).must_equal(first: :yes, second: :no)
347+
end
348+
349+
it 'installs options defined by environment variable and overrides defaults and user config' do
350+
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENUM_CONFIG_OPTS' => 'first=yes') do
351+
instance.install(first: :maybe, second: :no)
352+
_(instance.config).must_equal(first: :yes, second: :no)
353+
end
354+
end
355+
356+
it 'falls back to install options defined by user config when environment variable fails validation' do
357+
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENUM_CONFIG_OPTS' => 'first=perhaps') do
358+
instance.install(first: :maybe, second: :no)
359+
_(instance.config).must_equal(first: :maybe, second: :no)
360+
end
361+
end
362+
363+
it 'allows a callable option to be passed' do
364+
OpenTelemetry::TestHelpers.with_env('OTEL_RUBY_INSTRUMENTATION_ENUM_CONFIG_OPTS' => 'first=perhaps') do
365+
instance.install(first: :maybe, second: ->() { :yes })
366+
_(instance.config[:first]).must_equal(:maybe)
367+
_(instance.config[:second].call).must_equal(:yes)
368+
end
369+
end
370+
end
320371
end
321372

322373
describe 'when uninstallable' do

instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/instrumentation.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ module Sidekiq
3737
# - `:none` - The job will be represented by a separate trace from the span that enqueued the job.
3838
# There will be no explicit relationship between the job trace and the trace containing the span that
3939
# enqueued the job.
40+
# - Can alternatively be a callable which resolves to one of the above values.
4041
#
4142
# ### `:trace_launcher_heartbeat`
4243
#
@@ -101,7 +102,7 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base
101102
end
102103

103104
option :span_naming, default: :queue, validate: %I[job_class queue]
104-
option :propagation_style, default: :link, validate: %i[link child none]
105+
option :propagation_style, default: :link, validate: DynamicValidator.new(%i[link child none])
105106
option :trace_launcher_heartbeat, default: false, validate: :boolean
106107
option :trace_poller_enqueue, default: false, validate: :boolean
107108
option :trace_poller_wait, default: false, validate: :boolean

instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/server/tracer_middleware.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,15 @@ def call(_worker, msg, _queue)
3333
extracted_context = OpenTelemetry.propagation.extract(msg)
3434
created_at = time_from_timestamp(msg['created_at'])
3535
enqueued_at = time_from_timestamp(msg['created_at'])
36+
propagation_style =
37+
if instrumentation_config[:propagation_style].respond_to?(:call)
38+
instrumentation_config[:propagation_style].call
39+
else
40+
instrumentation_config[:propagation_style]
41+
end
42+
3643
OpenTelemetry::Context.with_current(extracted_context) do
37-
if instrumentation_config[:propagation_style] == :child
44+
if propagation_style == :child
3845
tracer.in_span(span_name, attributes: attributes, kind: :consumer) do |span|
3946
span.add_event('created_at', timestamp: created_at)
4047
span.add_event('enqueued_at', timestamp: enqueued_at)
@@ -43,7 +50,7 @@ def call(_worker, msg, _queue)
4350
else
4451
links = []
4552
span_context = OpenTelemetry::Trace.current_span(extracted_context).context
46-
links << OpenTelemetry::Trace::Link.new(span_context) if instrumentation_config[:propagation_style] == :link && span_context.valid?
53+
links << OpenTelemetry::Trace::Link.new(span_context) if propagation_style == :link && span_context.valid?
4754
span = tracer.start_root_span(span_name, attributes: attributes, links: links, kind: :consumer)
4855
OpenTelemetry::Trace.with_span(span) do
4956
span.add_event('created_at', timestamp: created_at)

instrumentation/sidekiq/test/opentelemetry/instrumentation/sidekiq/middlewares/server/tracer_middleware_test.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,64 @@
226226
end
227227
end
228228

229+
describe 'when propagation_style is a callable that resolves to `:child`' do
230+
let(:config) do
231+
{ propagation_style: ->() { :child } }
232+
end
233+
234+
it 'continues the enqueuer trace to the job process' do
235+
SimpleJob.perform_async
236+
SimpleJob.drain
237+
238+
_(job_span.parent_span_id).must_equal(enqueuer_span.span_id)
239+
_(job_span.trace_id).must_equal(enqueuer_span.trace_id)
240+
end
241+
242+
it 'fan out jobs are a continous trace' do
243+
SimpleEnqueueingJob.perform_async
244+
Sidekiq::Worker.drain_all
245+
246+
_(exporter.finished_spans.size).must_equal 4
247+
248+
_(root_span.parent_span_id).must_equal OpenTelemetry::Trace::INVALID_SPAN_ID
249+
_(root_span.name).must_equal 'default publish'
250+
_(root_span.kind).must_equal :producer
251+
252+
child_span1 = spans.find { |s| s.parent_span_id == root_span.span_id }
253+
_(child_span1.name).must_equal 'default process'
254+
_(child_span1.kind).must_equal :consumer
255+
256+
child_span2 = spans.find { |s| s.parent_span_id == child_span1.span_id }
257+
_(child_span2.name).must_equal 'default publish'
258+
_(child_span2.kind).must_equal :producer
259+
260+
child_span3 = spans.find { |s| s.parent_span_id == child_span2.span_id }
261+
_(child_span3.name).must_equal 'default process'
262+
_(child_span3.kind).must_equal :consumer
263+
end
264+
265+
it 'propagates baggage' do
266+
ctx = OpenTelemetry::Baggage.set_value('testing_baggage', 'it_worked')
267+
OpenTelemetry::Context.with_current(ctx) do
268+
BaggageTestingJob.perform_async
269+
end
270+
271+
Sidekiq::Worker.drain_all
272+
273+
_(job_span.attributes['success']).must_equal(true)
274+
end
275+
276+
it 'records exceptions' do
277+
ExceptionTestingJob.perform_async
278+
_(-> { Sidekiq::Worker.drain_all }).must_raise(RuntimeError)
279+
280+
ev = job_span.events
281+
_(ev[2].attributes['exception.type']).must_equal('RuntimeError')
282+
_(ev[2].attributes['exception.message']).must_equal('a little hell')
283+
_(ev[2].attributes['exception.stacktrace']).wont_be_nil
284+
end
285+
end
286+
229287
describe 'when propagation_style is none' do
230288
let(:config) { { propagation_style: :none } }
231289

0 commit comments

Comments
 (0)