diff --git a/instrumentation/base/Appraisals b/instrumentation/base/Appraisals new file mode 100644 index 000000000..b9f4a41c3 --- /dev/null +++ b/instrumentation/base/Appraisals @@ -0,0 +1,6 @@ +appraise "base" do + remove_gem "opentelemetry-metrics-api" +end + +appraise "metrics-api" do +end diff --git a/instrumentation/base/Gemfile b/instrumentation/base/Gemfile index f649e2f64..07d2b5728 100644 --- a/instrumentation/base/Gemfile +++ b/instrumentation/base/Gemfile @@ -6,4 +6,6 @@ source 'https://rubygems.org' +gem 'opentelemetry-metrics-api' + gemspec diff --git a/instrumentation/base/lib/opentelemetry/instrumentation/base.rb b/instrumentation/base/lib/opentelemetry/instrumentation/base.rb index ddf828955..cfb124ce2 100644 --- a/instrumentation/base/lib/opentelemetry/instrumentation/base.rb +++ b/instrumentation/base/lib/opentelemetry/instrumentation/base.rb @@ -69,8 +69,9 @@ class << self integer: ->(v) { v.is_a?(Integer) }, string: ->(v) { v.is_a?(String) } }.freeze + SINGLETON_MUTEX = Thread::Mutex.new - private_constant :NAME_REGEX, :VALIDATORS + private_constant :NAME_REGEX, :VALIDATORS, :SINGLETON_MUTEX private :new @@ -163,13 +164,46 @@ def option(name, default:, validate:) end def instance - @instance ||= new(instrumentation_name, instrumentation_version, install_blk, - present_blk, compatible_blk, options) + @instance || SINGLETON_MUTEX.synchronize do + @instance ||= new(instrumentation_name, instrumentation_version, install_blk, + present_blk, compatible_blk, options, instrument_configs) + end + end + + if defined?(OpenTelemetry::Metrics) + %i[ + counter asynchronous_counter + histogram gauge asynchronous_gauge + updown_counter asynchronous_updown_counter + ].each do |instrument_kind| + define_method(instrument_kind) do |name, **opts| + register_instrument(instrument_kind, name, **opts) + end + end + + def register_instrument(kind, name, **opts) + @instrument_configs ||= {} + + key = [kind, name] + if @instrument_configs.key?(key) + warn("Duplicate instrument configured for #{self}: #{key.inspect}") + else + @instrument_configs[key] = opts + end + end + else + def counter(*, **); end + def asynchronous_counter(*, **); end + def histogram(*, **); end + def gauge(*, **); end + def asynchronous_gauge(*, **); end + def updown_counter(*, **); end + def asynchronous_updown_counter(*, **); end end private - attr_reader :install_blk, :present_blk, :compatible_blk, :options + attr_reader :install_blk, :present_blk, :compatible_blk, :options, :instrument_configs def infer_name @inferred_name ||= if (md = name.match(NAME_REGEX)) # rubocop:disable Naming/MemoizedInstanceVariableName @@ -177,6 +211,10 @@ def infer_name end end + def metrics_defined? + defined?(OpenTelemetry::Metrics) + end + def infer_version return unless (inferred_name = infer_name) @@ -189,13 +227,13 @@ def infer_version end end - attr_reader :name, :version, :config, :installed, :tracer + attr_reader :name, :version, :config, :installed, :tracer, :meter, :instrument_configs alias installed? installed # rubocop:disable Metrics/ParameterLists def initialize(name, version, install_blk, present_blk, - compatible_blk, options) + compatible_blk, options, instrument_configs) @name = name @version = version @install_blk = install_blk @@ -204,7 +242,9 @@ def initialize(name, version, install_blk, present_blk, @config = {} @installed = false @options = options - @tracer = OpenTelemetry::Trace::Tracer.new + @tracer = OpenTelemetry::Trace::Tracer.new # default no-op tracer + @meter = OpenTelemetry::Metrics::Meter.new if defined?(OpenTelemetry::Metrics::Meter) # default no-op meter + @instrument_configs = instrument_configs || {} end # rubocop:enable Metrics/ParameterLists @@ -217,10 +257,19 @@ def install(config = {}) return true if installed? @config = config_options(config) + + @metrics_enabled = compute_metrics_enabled + + if metrics_defined? + @metrics_instruments = {} + @instrument_mutex = Mutex.new + end + return false unless installable?(config) instance_exec(@config, &@install_blk) @tracer = OpenTelemetry.tracer_provider.tracer(name, version) + @meter = OpenTelemetry.meter_provider.meter(name, version: version) if metrics_enabled? @installed = true end @@ -261,8 +310,76 @@ def enabled?(config = nil) true end + # This is based on a variety of factors, and should be invalidated when @config changes. + # It should be explicitly set in `initialize` for now. + def metrics_enabled? + !!@metrics_enabled + end + + # @api private + # ONLY yields if the meter is enabled. + def with_meter + yield @meter if metrics_enabled? + end + + if defined?(OpenTelemetry::Metrics) + %i[ + counter + asynchronous_counter + histogram + gauge + asynchronous_gauge + updown_counter + asynchronous_updown_counter + ].each do |kind| + define_method(kind) do |name| + get_metrics_instrument(kind, name) + end + end + end + private + def metrics_defined? + defined?(OpenTelemetry::Metrics) + end + + def get_metrics_instrument(kind, name) + # FIXME: we should probably return *something* + # if metrics is not enabled, but if the api is undefined, + # it's unclear exactly what would be suitable. + # For now, there are no public methods that call this + # if metrics isn't defined. + return unless metrics_defined? + + @metrics_instruments.fetch([kind, name]) do |key| + @instrument_mutex.synchronize do + @metrics_instruments[key] ||= create_configured_instrument(kind, name) + end + end + end + + def create_configured_instrument(kind, name) + config = @instrument_configs[[kind, name]] + + # FIXME: what is appropriate here? + if config.nil? + Kernel.warn("unconfigured instrument requested: #{kind} of '#{name}'") + return + end + + # FIXME: some of these have different opts; + # should verify that they work before this point. + meter.public_send(:"create_#{kind}", name, **config) + end + + def compute_metrics_enabled + return false unless defined?(OpenTelemetry::Metrics) + return false if metrics_disabled_by_env_var? + + !!@config[:metrics] || metrics_enabled_by_env_var? + end + # 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. @@ -317,13 +434,42 @@ def config_options(user_config) # will be OTEL_RUBY_INSTRUMENTATION_SINATRA_ENABLED. A value of 'false' will disable # the instrumentation, all other values will enable it. def enabled_by_env_var? + !disabled_by_env_var? + end + + def disabled_by_env_var? var_name = name.dup.tap do |n| n.upcase! n.gsub!('::', '_') n.gsub!('OPENTELEMETRY_', 'OTEL_RUBY_') n << '_ENABLED' end - ENV[var_name] != 'false' + ENV[var_name] == 'false' + end + + # Checks if this instrumentation's metrics are enabled by env var. + # This follows the conventions as outlined above, using `_METRICS_ENABLED` as a suffix. + # Unlike INSTRUMENTATION_*_ENABLED variables, these are explicitly opt-in (i.e. + # if the variable is unset, and `metrics: true` is not in the instrumentation's config, + # the metrics will not be enabled) + def metrics_enabled_by_env_var? + ENV.key?(metrics_env_var_name) && ENV[metrics_env_var_name] != 'false' + end + + def metrics_disabled_by_env_var? + ENV[metrics_env_var_name] == 'false' + end + + def metrics_env_var_name + @metrics_env_var_name ||= + begin + var_name = name.dup + var_name.upcase! + var_name.gsub!('::', '_') + var_name.gsub!('OPENTELEMETRY_', 'OTEL_RUBY_') + var_name << '_METRICS_ENABLED' + var_name + end end # Checks to see if the user has passed any environment variables that set options diff --git a/instrumentation/base/opentelemetry-instrumentation-base.gemspec b/instrumentation/base/opentelemetry-instrumentation-base.gemspec index 9bcca6200..1c3472e6a 100644 --- a/instrumentation/base/opentelemetry-instrumentation-base.gemspec +++ b/instrumentation/base/opentelemetry-instrumentation-base.gemspec @@ -29,6 +29,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'opentelemetry-common', '~> 0.21' spec.add_dependency 'opentelemetry-registry', '~> 0.1' + spec.add_development_dependency 'appraisal', '~> 2.5' spec.add_development_dependency 'bundler', '~> 2.4' spec.add_development_dependency 'minitest', '~> 5.0' spec.add_development_dependency 'opentelemetry-test-helpers', '~> 0.3' diff --git a/instrumentation/base/test/instrumentation/base_test.rb b/instrumentation/base/test/instrumentation/base_test.rb index c58bbe500..88afe7ade 100644 --- a/instrumentation/base/test/instrumentation/base_test.rb +++ b/instrumentation/base/test/instrumentation/base_test.rb @@ -53,15 +53,86 @@ def initialize(*args) end end + let(:instrumentation_with_metrics) do + Class.new(OpenTelemetry::Instrumentation::Base) do + instrumentation_name 'test_instrumentation' + instrumentation_version '0.1.1' + + option :metrics, default: false, validate: :boolean + + if defined?(OpenTelemetry::Metrics) + counter 'example.counter' + asynchronous_counter 'example.asynchronous_counter' + histogram 'example.histogram' + gauge 'example.gauge' + asynchronous_gauge 'example.asynchronous_gauge' + updown_counter 'example.updown_counter' + asynchronous_updown_counter 'example.asynchronous_updown_counter' + end + + def example_counter + counter 'example.counter' + end + + def example_asynchronous_counter + asynchronous_counter 'example.asynchronous_counter' + end + + def example_histogram + histogram 'example.histogram' + end + + def example_gauge + gauge 'example.gauge' + end + + def example_asynchronous_gauge + asynchronous_gauge 'example.asynchronous_gauge' + end + + def example_updown_counter + updown_counter 'example.updown_counter' + end + + def example_asynchronous_updown_counter + asynchronous_updown_counter 'example.asynchronous_updown_counter' + end + end + end + it 'is auto-registered' do instance = instrumentation.instance _(OpenTelemetry::Instrumentation.registry.lookup('test_instrumentation')).must_equal(instance) end describe '.instance' do + let(:instrumentation) do + Class.new(OpenTelemetry::Instrumentation::Base) do + instrumentation_name 'test_instrumentation' + instrumentation_version '0.1.1' + + def initialize(*args) + # Simulate latency by hinting the VM should switch tasks + # (this can also be accomplished by something like `sleep(0.1)`). + # This replicates the worst-case scenario when using default assignment + # to obtain a singleton, i.e. that the scheduler switches threads between + # the nil check and object initialization. + Thread.pass + super + end + end + end + it 'returns an instance' do _(instrumentation.instance).must_be_instance_of(instrumentation) end + + it 'returns the same singleton instance to every thread' do + object_ids = Array.new(2).map { Thread.new { instrumentation.instance } } + .map { |thr| thr.join.value } + + _(object_ids.uniq.count).must_equal(1) + end end describe '.option' do @@ -442,6 +513,55 @@ def initialize(*args) end end + describe 'metrics' do + let(:config) { {} } + let(:instance) { instrumentation_with_metrics.instance } + + before do + instance.install(config) + end + + if defined?(OpenTelemetry::Metrics) + describe 'with the metrics api' do + it 'is disabled by default' do + _(instance.metrics_enabled?).must_equal false + end + + it 'returns a no-op counter' do + counter = instance.example_counter + _(counter).must_be_kind_of(OpenTelemetry::Metrics::Instrument::Counter) + end + + describe 'with the option enabled' do + let(:config) { { metrics: true } } + + it 'will be enabled' do + _(instance.metrics_enabled?).must_equal true + end + + it 'returns a no-op counter' do + counter = instance.example_counter + _(counter).must_be_kind_of(OpenTelemetry::Metrics::Instrument::Counter) + end + end + end + else + describe 'without the metrics api' do + it 'will not be enabled' do + _(instance.metrics_enabled?).must_equal false + end + + describe 'with the option enabled' do + let(:config) { { metrics: true } } + + it 'will not be enabled' do + _(instance.metrics_enabled?).must_equal false + end + end + end + end + end + def define_instrumentation_subclass(name, version = nil) names = name.split('::').map(&:to_sym) names.inject(Object) do |object, const|