Skip to content

Commit

Permalink
feat: Gradually add metrics capabilities to Instrumentation::Base
Browse files Browse the repository at this point in the history
  • Loading branch information
zvkemp committed Jan 24, 2025
1 parent f12b0d7 commit 5f393f8
Show file tree
Hide file tree
Showing 7 changed files with 362 additions and 11 deletions.
13 changes: 13 additions & 0 deletions instrumentation/base/Appraisals
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

appraise 'base' do
remove_gem 'opentelemetry-metrics-api'
remove_gem 'opentelemetry-metrics-sdk'
end

appraise 'metrics-api' do
remove_gem 'opentelemetry-metrics-sdk'
end

appraise 'metrics-sdk' do # rubocop: disable Lint/EmptyBlock
end
3 changes: 3 additions & 0 deletions instrumentation/base/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@

source 'https://rubygems.org'

gem 'opentelemetry-metrics-api', '~> 0.2'
gem 'opentelemetry-metrics-sdk'

gemspec
34 changes: 25 additions & 9 deletions instrumentation/base/lib/opentelemetry/instrumentation/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -163,8 +164,10 @@ 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)
end
end

private
Expand All @@ -189,13 +192,15 @@ def infer_version
end
end

attr_reader :name, :version, :config, :installed, :tracer
attr_reader :name, :version, :config, :installed, :tracer, :meter

alias installed? installed

require_relative 'metrics'
prepend(OpenTelemetry::Instrumentation::Metrics)

# rubocop:disable Metrics/ParameterLists
def initialize(name, version, install_blk, present_blk,
compatible_blk, options)
def initialize(name, version, install_blk, present_blk, compatible_blk, options)
@name = name
@version = version
@install_blk = install_blk
Expand All @@ -204,7 +209,8 @@ 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
end
# rubocop:enable Metrics/ParameterLists

Expand All @@ -217,10 +223,12 @@ def install(config = {})
return true if installed?

@config = config_options(config)

return false unless installable?(config)

prepare_install
instance_exec(@config, &@install_blk)
@tracer = OpenTelemetry.tracer_provider.tracer(name, version)

@installed = true
end

Expand Down Expand Up @@ -263,6 +271,10 @@ def enabled?(config = nil)

private

def prepare_install
@tracer = OpenTelemetry.tracer_provider.tracer(name, version)
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.
Expand Down Expand Up @@ -317,13 +329,17 @@ 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 to see if the user has passed any environment variables that set options
Expand Down
192 changes: 192 additions & 0 deletions instrumentation/base/lib/opentelemetry/instrumentation/metrics.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# frozen_string_literal: true

# Copyright The OpenTelemetry Authors
#
# SPDX-License-Identifier: Apache-2.0

module OpenTelemetry
module Instrumentation
# Extensions to Instrumentation::Base that handle metrics instruments.
# The goal here is to allow metrics to be added gradually to instrumentation libraries,
# without requiring that the metrics-sdk or metrics-api gems are present in the bundle
# (if they are not, or if the metrics-api gem does not meet the minimum version requirement,
# the no-op edition is installed.)
module Metrics
METER_TYPES = %i[
counter
observable_counter
histogram
gauge
observable_gauge
up_down_counter
observable_up_down_counter
].freeze

def self.prepended(base)
base.prepend(Compatibility)
base.extend(Compatibility)
base.extend(Registration)

if base.metrics_compatible?
base.prepend(Extensions)
else
base.prepend(NoopExtensions)
end
end

# Methods to check whether the metrics API is defined
# and is a compatible version
module Compatibility
METRICS_API_MINIMUM_GEM_VERSION = Gem::Version.new('0.2.0')

def metrics_defined?
defined?(OpenTelemetry::Metrics)
end

def metrics_compatible?
metrics_defined? && Gem.loaded_specs['opentelemetry-metrics-api'].version >= METRICS_API_MINIMUM_GEM_VERSION
end

extend(self)
end

# class-level methods to declare and register metrics instruments.
# This can be extended even if metrics is not active or present.
module Registration
METER_TYPES.each do |instrument_kind|
define_method(instrument_kind) do |name, **opts, &block|
opts[:callback] ||= block if block
register_instrument(instrument_kind, name, **opts)
end
end

def register_instrument(kind, name, **opts)
key = [kind, name]
if instrument_configs.key?(key)
warn("Duplicate instrument configured for #{self}: #{key.inspect}")
else
instrument_configs[key] = opts
end
end

def instrument_configs
@instrument_configs ||= {}
end
end

# No-op instance methods for metrics instruments.
module NoopExtensions
METER_TYPES.each do |kind|
define_method(kind) {} # rubocop: disable Lint/EmptyBlock
end

def with_meter; end

def metrics_enabled?
false
end
end

# Instance methods for metrics instruments.
module Extensions
%i[
counter
observable_counter
histogram
gauge
observable_gauge
up_down_counter
observable_up_down_counter
].each do |kind|
define_method(kind) do |name|
get_metrics_instrument(kind, name)
end
end

# This is based on a variety of factors, and should be invalidated when @config changes.
# It should be explicitly set in `prepare_install` 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

private

def compute_metrics_enabled
return false unless metrics_compatible?
return false if metrics_disabled_by_env_var?

!!@config[:metrics] || metrics_enabled_by_env_var?
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

def prepare_install
@metrics_enabled = compute_metrics_enabled
if metrics_defined?
@metrics_instruments = {}
@instrument_mutex = Mutex.new
end

@meter = OpenTelemetry.meter_provider.meter(name, version: version) if metrics_enabled?

super
end

def get_metrics_instrument(kind, name)
# TODO: 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 = self.class.instrument_configs[[kind, name]]

if config.nil?
Kernel.warn("unconfigured instrument requested: #{kind} of '#{name}'")
return
end

meter.public_send(:"create_#{kind}", name, **config)
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading

0 comments on commit 5f393f8

Please sign in to comment.