Skip to content

Commit 3d758f8

Browse files
committed
feat: Gradually add metrics capabilities to Instrumentation::Base
1 parent 4673272 commit 3d758f8

File tree

8 files changed

+386
-10
lines changed

8 files changed

+386
-10
lines changed

instrumentation/base/Appraisals

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
appraise 'base' do # rubocop: disable Lint/EmptyBlock
4+
end
5+
6+
appraise 'metrics-api' do
7+
gem 'opentelemetry-metrics-api'
8+
end
9+
10+
appraise 'metrics-sdk' do # rubocop: disable Lint/EmptyBlock
11+
gem 'opentelemetry-sdk'
12+
gem 'opentelemetry-metrics-sdk'
13+
end

instrumentation/base/Gemfile

+2
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@
66

77
source 'https://rubygems.org'
88

9+
gem 'opentelemetry-test-helpers', github: 'zvkemp/opentelemetry-ruby', ref: 'test-helpers-metrics'
10+
911
gemspec

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

+25-9
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ class << self
6969
integer: ->(v) { v.is_a?(Integer) },
7070
string: ->(v) { v.is_a?(String) }
7171
}.freeze
72+
SINGLETON_MUTEX = Thread::Mutex.new
7273

73-
private_constant :NAME_REGEX, :VALIDATORS
74+
private_constant :NAME_REGEX, :VALIDATORS, :SINGLETON_MUTEX
7475

7576
private :new
7677

@@ -163,8 +164,10 @@ def option(name, default:, validate:)
163164
end
164165

165166
def instance
166-
@instance ||= new(instrumentation_name, instrumentation_version, install_blk,
167-
present_blk, compatible_blk, options)
167+
@instance || SINGLETON_MUTEX.synchronize do
168+
@instance ||= new(instrumentation_name, instrumentation_version, install_blk,
169+
present_blk, compatible_blk, options)
170+
end
168171
end
169172

170173
private
@@ -189,13 +192,15 @@ def infer_version
189192
end
190193
end
191194

192-
attr_reader :name, :version, :config, :installed, :tracer
195+
attr_reader :name, :version, :config, :installed, :tracer, :meter
193196

194197
alias installed? installed
195198

199+
require_relative 'metrics'
200+
prepend(OpenTelemetry::Instrumentation::Metrics)
201+
196202
# rubocop:disable Metrics/ParameterLists
197-
def initialize(name, version, install_blk, present_blk,
198-
compatible_blk, options)
203+
def initialize(name, version, install_blk, present_blk, compatible_blk, options)
199204
@name = name
200205
@version = version
201206
@install_blk = install_blk
@@ -204,7 +209,8 @@ def initialize(name, version, install_blk, present_blk,
204209
@config = {}
205210
@installed = false
206211
@options = options
207-
@tracer = OpenTelemetry::Trace::Tracer.new
212+
@tracer = OpenTelemetry::Trace::Tracer.new # default no-op tracer
213+
@meter = OpenTelemetry::Metrics::Meter.new if defined?(OpenTelemetry::Metrics::Meter) # default no-op meter
208214
end
209215
# rubocop:enable Metrics/ParameterLists
210216

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

219225
@config = config_options(config)
226+
220227
return false unless installable?(config)
221228

229+
prepare_install
222230
instance_exec(@config, &@install_blk)
223-
@tracer = OpenTelemetry.tracer_provider.tracer(name, version)
231+
224232
@installed = true
225233
end
226234

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

264272
private
265273

274+
def prepare_install
275+
@tracer = OpenTelemetry.tracer_provider.tracer(name, version)
276+
end
277+
266278
# The config_options method is responsible for validating that the user supplied
267279
# config hash is valid.
268280
# Unknown configuration keys are not included in the final config hash.
@@ -317,13 +329,17 @@ def config_options(user_config)
317329
# will be OTEL_RUBY_INSTRUMENTATION_SINATRA_ENABLED. A value of 'false' will disable
318330
# the instrumentation, all other values will enable it.
319331
def enabled_by_env_var?
332+
!disabled_by_env_var?
333+
end
334+
335+
def disabled_by_env_var?
320336
var_name = name.dup.tap do |n|
321337
n.upcase!
322338
n.gsub!('::', '_')
323339
n.gsub!('OPENTELEMETRY_', 'OTEL_RUBY_')
324340
n << '_ENABLED'
325341
end
326-
ENV[var_name] != 'false'
342+
ENV[var_name] == 'false'
327343
end
328344

329345
# Checks to see if the user has passed any environment variables that set options
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
begin
8+
# mitigate potential load order issues by always requiring
9+
# the metrics-api gem if it is in the bundle. This module is
10+
# intended to function with or without it, but loading it after
11+
# any of the definition queries have been made may cause false negatives.
12+
require 'opentelemetry-metrics-api'
13+
rescue LoadError # rubocop: disable Lint/SuppressedException
14+
end
15+
16+
module OpenTelemetry
17+
module Instrumentation
18+
# Extensions to Instrumentation::Base that handle metrics instruments.
19+
# The goal here is to allow metrics to be added gradually to instrumentation libraries,
20+
# without requiring that the metrics-sdk or metrics-api gems are present in the bundle
21+
# (if they are not, or if the metrics-api gem does not meet the minimum version requirement,
22+
# the no-op edition is installed.)
23+
module Metrics
24+
METER_TYPES = %i[
25+
counter
26+
observable_counter
27+
histogram
28+
gauge
29+
observable_gauge
30+
up_down_counter
31+
observable_up_down_counter
32+
].freeze
33+
34+
def self.prepended(base)
35+
base.prepend(Compatibility)
36+
base.extend(Compatibility)
37+
base.extend(Registration)
38+
39+
if base.metrics_compatible?
40+
base.prepend(Extensions)
41+
else
42+
base.prepend(NoopExtensions)
43+
end
44+
end
45+
46+
# Methods to check whether the metrics API is defined
47+
# and is a compatible version
48+
module Compatibility
49+
METRICS_API_MINIMUM_GEM_VERSION = Gem::Version.new('0.2.0')
50+
51+
def metrics_defined?
52+
defined?(OpenTelemetry::Metrics)
53+
end
54+
55+
def metrics_compatible?
56+
metrics_defined? && Gem.loaded_specs['opentelemetry-metrics-api'].version >= METRICS_API_MINIMUM_GEM_VERSION
57+
end
58+
59+
extend(self)
60+
end
61+
62+
# class-level methods to declare and register metrics instruments.
63+
# This can be extended even if metrics is not active or present.
64+
module Registration
65+
METER_TYPES.each do |instrument_kind|
66+
define_method(instrument_kind) do |name, **opts, &block|
67+
opts[:callback] ||= block if block
68+
register_instrument(instrument_kind, name, **opts)
69+
end
70+
end
71+
72+
def register_instrument(kind, name, **opts)
73+
key = [kind, name]
74+
if instrument_configs.key?(key)
75+
warn("Duplicate instrument configured for #{self}: #{key.inspect}")
76+
else
77+
instrument_configs[key] = opts
78+
end
79+
end
80+
81+
def instrument_configs
82+
@instrument_configs ||= {}
83+
end
84+
end
85+
86+
# No-op instance methods for metrics instruments.
87+
module NoopExtensions
88+
METER_TYPES.each do |kind|
89+
define_method(kind) { |*, **| } # rubocop: disable Lint/EmptyBlock
90+
end
91+
92+
def with_meter; end
93+
94+
def metrics_enabled?
95+
false
96+
end
97+
end
98+
99+
# Instance methods for metrics instruments.
100+
module Extensions
101+
%i[
102+
counter
103+
observable_counter
104+
histogram
105+
gauge
106+
observable_gauge
107+
up_down_counter
108+
observable_up_down_counter
109+
].each do |kind|
110+
define_method(kind) do |name|
111+
get_metrics_instrument(kind, name)
112+
end
113+
end
114+
115+
# This is based on a variety of factors, and should be invalidated when @config changes.
116+
# It should be explicitly set in `prepare_install` for now.
117+
def metrics_enabled?
118+
!!@metrics_enabled
119+
end
120+
121+
# @api private
122+
# ONLY yields if the meter is enabled.
123+
def with_meter
124+
yield @meter if metrics_enabled?
125+
end
126+
127+
private
128+
129+
def compute_metrics_enabled
130+
return false unless metrics_compatible?
131+
return false if metrics_disabled_by_env_var?
132+
133+
!!@config[:metrics] || metrics_enabled_by_env_var?
134+
end
135+
136+
# Checks if this instrumentation's metrics are enabled by env var.
137+
# This follows the conventions as outlined above, using `_METRICS_ENABLED` as a suffix.
138+
# Unlike INSTRUMENTATION_*_ENABLED variables, these are explicitly opt-in (i.e.
139+
# if the variable is unset, and `metrics: true` is not in the instrumentation's config,
140+
# the metrics will not be enabled)
141+
def metrics_enabled_by_env_var?
142+
ENV.key?(metrics_env_var_name) && ENV[metrics_env_var_name] != 'false'
143+
end
144+
145+
def metrics_disabled_by_env_var?
146+
ENV[metrics_env_var_name] == 'false'
147+
end
148+
149+
def metrics_env_var_name
150+
@metrics_env_var_name ||=
151+
begin
152+
var_name = name.dup
153+
var_name.upcase!
154+
var_name.gsub!('::', '_')
155+
var_name.gsub!('OPENTELEMETRY_', 'OTEL_RUBY_')
156+
var_name << '_METRICS_ENABLED'
157+
var_name
158+
end
159+
end
160+
161+
def prepare_install
162+
@metrics_enabled = compute_metrics_enabled
163+
if metrics_defined?
164+
@metrics_instruments = {}
165+
@instrument_mutex = Mutex.new
166+
end
167+
168+
@meter = OpenTelemetry.meter_provider.meter(name, version: version) if metrics_enabled?
169+
170+
super
171+
end
172+
173+
def get_metrics_instrument(kind, name)
174+
# TODO: we should probably return *something*
175+
# if metrics is not enabled, but if the api is undefined,
176+
# it's unclear exactly what would be suitable.
177+
# For now, there are no public methods that call this
178+
# if metrics isn't defined.
179+
return unless metrics_defined?
180+
181+
@metrics_instruments.fetch([kind, name]) do |key|
182+
@instrument_mutex.synchronize do
183+
@metrics_instruments[key] ||= create_configured_instrument(kind, name)
184+
end
185+
end
186+
end
187+
188+
def create_configured_instrument(kind, name)
189+
config = self.class.instrument_configs[[kind, name]]
190+
191+
if config.nil?
192+
Kernel.warn("unconfigured instrument requested: #{kind} of '#{name}'")
193+
return
194+
end
195+
196+
meter.public_send(:"create_#{kind}", name, **config)
197+
end
198+
end
199+
end
200+
end
201+
end

instrumentation/base/opentelemetry-instrumentation-base.gemspec

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
2929
spec.add_dependency 'opentelemetry-common', '~> 0.21'
3030
spec.add_dependency 'opentelemetry-registry', '~> 0.1'
3131

32+
spec.add_development_dependency 'appraisal', '~> 2.5'
3233
spec.add_development_dependency 'bundler', '~> 2.4'
3334
spec.add_development_dependency 'minitest', '~> 5.0'
3435
spec.add_development_dependency 'opentelemetry-test-helpers', '~> 0.3'

instrumentation/base/test/instrumentation/base_test.rb

+29-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
require 'test_helper'
88

99
describe OpenTelemetry::Instrumentation::Base do
10-
after { OpenTelemetry::Instrumentation.instance_variable_set(:@registry, nil) }
10+
before { OpenTelemetry::Instrumentation.instance_variable_set(:@registry, nil) }
1111

1212
let(:instrumentation) do
1313
Class.new(OpenTelemetry::Instrumentation::Base) do
@@ -59,9 +59,37 @@ def initialize(*args)
5959
end
6060

6161
describe '.instance' do
62+
let(:instrumentation) do
63+
Class.new(OpenTelemetry::Instrumentation::Base) do
64+
instrumentation_name 'test_instrumentation'
65+
instrumentation_version '0.1.1'
66+
67+
def initialize(*args)
68+
# Simulate latency by hinting the VM should switch tasks
69+
# (this can also be accomplished by something like `sleep(0.1)`).
70+
# This replicates the worst-case scenario when using default assignment
71+
# to obtain a singleton, i.e. that the scheduler switches threads between
72+
# the nil check and object initialization.
73+
Thread.pass
74+
super
75+
end
76+
end
77+
end
78+
6279
it 'returns an instance' do
6380
_(instrumentation.instance).must_be_instance_of(instrumentation)
6481
end
82+
83+
it 'returns the same singleton instance to every thread' do
84+
# let blocks are not synchronized in minitest, so we need to evaluate
85+
# it before spawning the threads
86+
inst = instrumentation
87+
88+
object_ids = Array.new(2).map { Thread.new { inst.instance } }
89+
.map { |thr| thr.join.value }
90+
91+
_(object_ids.uniq.count).must_equal(1)
92+
end
6593
end
6694

6795
describe '.option' do

0 commit comments

Comments
 (0)