diff --git a/.instrumentation_generator/templates/gemspec.tt b/.instrumentation_generator/templates/gemspec.tt index 7d5ee8de1..b9cc7e653 100644 --- a/.instrumentation_generator/templates/gemspec.tt +++ b/.instrumentation_generator/templates/gemspec.tt @@ -38,6 +38,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'webmock', '~> 3.7.6' spec.add_development_dependency 'yard', '~> 0.9' spec.add_development_dependency 'yard-doctest', '~> 0.1.6' + # TODO Add semantic version constraints + spec.add_development_dependency '<%= instrumentation_name %>' if spec.respond_to?(:metadata) spec.metadata['changelog_uri'] = "https://open-telemetry.github.io/opentelemetry-ruby/opentelemetry-instrumentation-<%= instrumentation_name %>/v#{OpenTelemetry::Instrumentation::<%= pascal_cased_instrumentation_name %>::VERSION}/file.CHANGELOG.html" diff --git a/.instrumentation_generator/templates/lib/instrumentation/instrumentation_name/instrumentation.rb.tt b/.instrumentation_generator/templates/lib/instrumentation/instrumentation_name/instrumentation.rb.tt index 72c3ff815..e5cf596a8 100644 --- a/.instrumentation_generator/templates/lib/instrumentation/instrumentation_name/instrumentation.rb.tt +++ b/.instrumentation_generator/templates/lib/instrumentation/instrumentation_name/instrumentation.rb.tt @@ -9,6 +9,8 @@ module OpenTelemetry module <%= pascal_cased_instrumentation_name %> # The Instrumentation class contains logic to detect and install the <%= pascal_cased_instrumentation_name %> instrumentation class Instrumentation < OpenTelemetry::Instrumentation::Base + instrumented_library_name '<%= instrumentation_name %>' + install do |_config| require_dependencies end diff --git a/instrumentation/base/lib/opentelemetry/instrumentation/base.rb b/instrumentation/base/lib/opentelemetry/instrumentation/base.rb index 582c96271..fb2c6a8ba 100644 --- a/instrumentation/base/lib/opentelemetry/instrumentation/base.rb +++ b/instrumentation/base/lib/opentelemetry/instrumentation/base.rb @@ -12,14 +12,15 @@ module Instrumentation # it with +OpenTelemetry.instrumentation_registry+ and make it available for # discovery and installation by an SDK. # - # A typical subclass of Base will provide an install block, a present - # block, and possibly a compatible block. Below is an - # example: + # A typical subclass of Base will provide a library name, an install block, a present block, e.g.: # # module OpenTelemetry # module Instrumentation # module Sinatra # class Instrumentation < OpenTelemetry::Instrumentation::Base + # # Declare instrumented library: + # instrumented_library_name 'sinatra' + # # install do |config| # # install instrumentation, either by library hook or applying # # a monkey patch @@ -29,10 +30,32 @@ module Instrumentation # present do # defined?(::Sinatra) # end + # end + # end + # end + # end + # + # In cases where the instrumentation is not able to use `instrumented_library_name` to check against gem specifications, + # subclasses may provide a custom code block to check compatibility at start up time, e.g. + # + # module OpenTelemetry + # module Instrumentation + # module CustomLibrary + # class Instrumentation < OpenTelemetry::Instrumentation::Base + # MIN_VERSION = '3.2.1' + # install do |config| + # # install instrumentation, either by library hook or applying + # # a monkey patch + # end + # + # # determine if the target library is present + # present do + # defined?(::CustomLibrary) + # end # # # if the target library is present, is it compatible? # compatible do - # Gem.loaded_specs['sinatra'].version > MIN_VERSION + # ::CustomLibrary::VERSION > MIN_VERSION # end # end # end @@ -107,6 +130,14 @@ def instrumentation_version(instrumentation_version = nil) end end + # Optionally set the library name being instrumented. + # + # Set this value in lieu writing a custom compatible block. + # @param [String] instrumented_library_name must match the gem specification name of the instrumented library + def instrumented_library_name(instrumented_library_name = nil) + @instrumented_library_name ||= instrumented_library_name + end + # The install block for this instrumentation. This will be where you install # instrumentation, either by framework hook or applying a monkey patch. # @@ -163,7 +194,7 @@ def option(name, default:, validate:) end def instance - @instance ||= new(instrumentation_name, instrumentation_version, install_blk, + @instance ||= new(instrumentation_name, instrumentation_version, instrumented_library_name, install_blk, present_blk, compatible_blk, options) end @@ -189,11 +220,11 @@ def infer_version end end - attr_reader :name, :version, :config, :installed, :tracer + attr_reader :name, :version, :config, :installed, :tracer, :instrumented_library_name, :instrumentation_gem_name alias installed? installed - def initialize(name, version, install_blk, present_blk, + def initialize(name, version, instrumented_library_name, install_blk, present_blk, compatible_blk, options) @name = name @version = version @@ -204,6 +235,8 @@ def initialize(name, version, install_blk, present_blk, @installed = false @options = options @tracer = OpenTelemetry::Trace::Tracer.new + @instrumented_library_name = instrumented_library_name + @instrumentation_gem_name = @instrumented_library_name.nil? ? nil : "opentelemetry-instrumentation-#{@instrumented_library_name}" end # Install instrumentation with the given config. The present? and compatible? @@ -238,12 +271,19 @@ def present? instance_exec(&@present_blk) end - # Calls the compatible block of the Instrumentation subclasses, if no block is provided - # it's assumed to be compatible + # Checks compatability of this instrumentation with its designated library + # + # Subclasses are compatible by default unless they specify + # - a custom compatible block + # - a `instrumented_library_name` use to compare gemspecs def compatible? - return true unless @compatible_blk + return true unless @compatible_blk || instrumented_library_name - instance_exec(&@compatible_blk) + if @compatible_blk + instance_exec(&@compatible_blk) + else + compatible_version? + end end # Whether this instrumentation is enabled. It first checks to see if it's enabled @@ -260,6 +300,27 @@ def enabled?(config = nil) private + # EXPERIMENTAL: Checks compatability of `instrumented_library_name` against the `development_dependency` declared in instrumentations gemspec + # + # This method will first search `Gem.loaded_specs` to locate gemspecs that have already been required by RubyGems or Bundler. + # In the case the gems were not loaded, or do not use RubyGems/Bundler, it will fallback to searching for installed gems via `Gem::Specification.find_by_name`. + # + # Instrumentation authors are encouraged to implement a `present` block to ensure the constants are availble to use in monkey patches. + # + # See https://github.com/DataDog/dd-trace-rb/pull/1510 + # rubocop:disable Metrics/AbcSize + def compatible_version? + instrumentation_spec = Gem.loaded_specs.fetch(instrumentation_gem_name) { Gem::Specification.find_by_name(instrumentation_gem_name) } + library_spec = Gem.loaded_specs.fetch(instrumented_library_name) { Gem::Specification.find_by_name(instrumented_library_name) } + + dependency = instrumentation_spec.development_dependencies.find { |spec| spec.name == instrumented_library_name } + dependency&.requirement&.satisfied_by?(library_spec&.version) == true + rescue Gem::MissingSpecError => e + OpenTelemetry.handle_error(message: 'Instrumentation Compatibility Check Error', exception: e) + false + end + # rubocop:enable Metrics/AbcSize + # The config_options method is responsible for validating that the user supplied # config hash is valid. # Unknown configuration keys are not included in the final config hash. diff --git a/instrumentation/base/test/instrumentation/base_test.rb b/instrumentation/base/test/instrumentation/base_test.rb index b853474df..8fa98b9c4 100644 --- a/instrumentation/base/test/instrumentation/base_test.rb +++ b/instrumentation/base/test/instrumentation/base_test.rb @@ -123,6 +123,133 @@ def initialize(*args) _(instance.compatible?).must_equal(true) end end + + describe 'with a library name' do + let(:instrumentation) do + Class.new(OpenTelemetry::Instrumentation::Base) do + instrumentation_name 'OpenTelemetry::Instrumentation::Example::Instrumentation' + instrumentation_version '0.0.1' + instrumented_library_name 'example' + end + end + + let(:instrumented_library_name) do + 'example' + end + + let(:library_gem_spec_version) do + '1.3.8.beta2' + end + + let(:instrumentation_gem_name) do + "opentelemetry-instrumentation-#{instrumented_library_name}" + end + + describe 'when comparing gemspecs' do + let(:library_gem_spec) do + Gem::Specification.new do |spec| + spec.name = instrumented_library_name + spec.version = library_gem_spec_version + end + end + + let(:instrumentation_gem_spec) do + Gem::Specification.new do |spec| + spec.name = instrumentation_gem_name + spec.add_development_dependency instrumented_library_name, '~> 1.1', '< 1.3.9' + end + end + + let(:loaded_specs) do + { + instrumentation_gem_name => instrumentation_gem_spec, + instrumented_library_name => library_gem_spec + } + end + + describe 'when gems are activated' do + describe 'with compatible versions' do + it 'returns true' do + Gem.stub(:loaded_specs, loaded_specs) do + _(instrumentation.instance.compatible?).must_equal(true) + end + end + end + + describe 'with incompatible versions' do + let(:library_gem_spec_version) do + '1.3.9' + end + + it 'returns false' do + Gem.stub(:loaded_specs, loaded_specs) do + _(instrumentation.instance.compatible?).must_equal(false) + end + end + end + end + + describe 'when gems were not activated (e.g. without using bundler)' do + describe 'with compatible versions' do + it 'returns true' do + Gem.stub(:loaded_specs, {}) do + Gem::Specification.stub(:find_by_name, ->(name) { loaded_specs[name] }) do + _(instrumentation.instance.compatible?).must_equal(true) + end + end + end + end + + describe 'with incompatible versions' do + let(:library_gem_spec_version) do + '1.3.9' + end + + it 'returns false' do + Gem.stub(:loaded_specs, {}) do + Gem::Specification.stub(:find_by_name, ->(name) { loaded_specs[name] }) do + _(instrumentation.instance.compatible?).must_equal(false) + end + end + end + end + end + + describe 'when the library is not installed' do + let(:loaded_specs) do + { instrumentation_gem_name => instrumentation_gem_spec } + end + + it 'returns false' do + Gem.stub(:loaded_specs, {}) do + gem_finder = lambda do |name| + raise Gem::MissingSpecError.new(name, name) unless loaded_specs[name] + + loaded_specs[name] + end + + Gem::Specification.stub(:find_by_name, gem_finder) do + _(instrumentation.instance.compatible?).must_equal(false) + end + end + end + end + + describe 'when library is not declared as a development dependency' do + let(:instrumentation_gem_spec) do + Gem::Specification.new do |spec| + spec.name = instrumentation_gem_name + end + end + + it 'returns false' do + Gem.stub(:loaded_specs, loaded_specs) do + _(instrumentation.instance.compatible?).must_equal(false) + end + end + end + end + end end describe '#install' do