Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Version Compatibility Based on Gem Specs #1025

Closed
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
2 changes: 2 additions & 0 deletions .instrumentation_generator/templates/gemspec.tt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 72 additions & 11 deletions instrumentation/base/lib/opentelemetry/instrumentation/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
#
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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?
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
127 changes: 127 additions & 0 deletions instrumentation/base/test/instrumentation/base_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down