From 8e4e07f3137bc93cc8cff84dcd56a15b53b31996 Mon Sep 17 00:00:00 2001
From: zvkemp <zvkemp@gmail.com>
Date: Wed, 18 Dec 2024 08:49:02 -0500
Subject: [PATCH] feat: metrics integration for sidekiq

---
 .../opentelemetry/instrumentation/metrics.rb  |   5 +
 ...ry-instrumentation-concurrent_ruby.gemspec |   1 +
 instrumentation/sidekiq/Appraisals            |  53 +++++----
 instrumentation/sidekiq/Gemfile               |   5 +-
 .../sidekiq/instrumentation.rb                |  10 ++
 .../middlewares/client/tracer_middleware.rb   |  41 ++++++-
 .../sidekiq/middlewares/common.rb             |  58 ++++++++++
 .../middlewares/server/tracer_middleware.rb   | 104 +++++++++++++-----
 ...ntelemetry-instrumentation-sidekiq.gemspec |   1 +
 .../client/tracer_middleware_test.rb          |  39 +++++++
 .../server/tracer_middleware_test.rb          |  61 +++++++++-
 instrumentation/sidekiq/test/test_helper.rb   |  79 +++++++++++++
 12 files changed, 408 insertions(+), 49 deletions(-)
 create mode 100644 instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/common.rb

diff --git a/instrumentation/base/lib/opentelemetry/instrumentation/metrics.rb b/instrumentation/base/lib/opentelemetry/instrumentation/metrics.rb
index 7f91bfa482..3d81fa260f 100644
--- a/instrumentation/base/lib/opentelemetry/instrumentation/metrics.rb
+++ b/instrumentation/base/lib/opentelemetry/instrumentation/metrics.rb
@@ -4,6 +4,11 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+begin
+  require 'opentelemetry-metrics-api'
+rescue LoadError
+end
+
 module OpenTelemetry
   module Instrumentation
     # Extensions to Instrumentation::Base that handle metrics instruments.
diff --git a/instrumentation/concurrent_ruby/opentelemetry-instrumentation-concurrent_ruby.gemspec b/instrumentation/concurrent_ruby/opentelemetry-instrumentation-concurrent_ruby.gemspec
index cb716de199..456fbca2a1 100644
--- a/instrumentation/concurrent_ruby/opentelemetry-instrumentation-concurrent_ruby.gemspec
+++ b/instrumentation/concurrent_ruby/opentelemetry-instrumentation-concurrent_ruby.gemspec
@@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
 
   spec.add_dependency 'opentelemetry-api', '~> 1.0'
   spec.add_dependency 'opentelemetry-instrumentation-base', '~> 0.23.0'
+  spec.add_dependency 'opentelemetry-metrics-api', '~> 1.0'
 
   spec.add_development_dependency 'appraisal', '~> 2.5'
   spec.add_development_dependency 'bundler', '~> 2.4'
diff --git a/instrumentation/sidekiq/Appraisals b/instrumentation/sidekiq/Appraisals
index 3b241272e4..b14ccb7043 100644
--- a/instrumentation/sidekiq/Appraisals
+++ b/instrumentation/sidekiq/Appraisals
@@ -1,24 +1,39 @@
 # frozen_string_literal: true
 
-appraise 'sidekiq-7.0' do
-  gem 'sidekiq', '~> 7.0'
-end
-
-appraise 'sidekiq-6.5' do
-  gem 'sidekiq', '>= 6.5', '< 7.0'
-end
+{
+  'sidekiq-7.0' => [['sidekiq', '~> 7.0']],
+  'sidekiq-6.5' => [['sidekiq', '>= 6.5', '< 7.0']],
+  'sidekiq-6.0' => [
+    ['sidekiq', '>= 6.0', '< 6.5'],
+    ['redis', '< 4.8']
+  ],
+  'sidekiq-5.2' => [
+    ['sidekiq', '~> 5.2'],
+    ['redis', '< 4.8']
+  ],
+  'sidekiq-4.2' => [
+    ['sidekiq', '~> 4.2'],
+    ['redis', '< 4.8']
+  ]
+}.each do |gemfile_name, specs|
+  appraise gemfile_name do
+    specs.each do |spec|
+      gem(*spec)
+      remove_gem 'opentelemetry-metrics-api'
+      remove_gem 'opentelemetry-metrics-sdk'
+    end
+  end
 
-appraise 'sidekiq-6.0' do
-  gem 'sidekiq', '>= 6.0', '< 6.5'
-  gem 'redis', '< 4.8'
-end
-
-appraise 'sidekiq-5.2' do
-  gem 'sidekiq', '~> 5.2'
-  gem 'redis', '< 4.8'
-end
+  appraise "#{gemfile_name}-metrics-api" do
+    specs.each do |spec|
+      gem(*spec)
+      remove_gem 'opentelemetry-metrics-sdk'
+    end
+  end
 
-appraise 'sidekiq-4.2' do
-  gem 'sidekiq', '~> 4.2'
-  gem 'redis', '< 4.8'
+  appraise "#{gemfile_name}-metrics-sdk" do
+    specs.each do |spec|
+      gem(*spec)
+    end
+  end
 end
diff --git a/instrumentation/sidekiq/Gemfile b/instrumentation/sidekiq/Gemfile
index 84efc8a188..6ea18fa95c 100644
--- a/instrumentation/sidekiq/Gemfile
+++ b/instrumentation/sidekiq/Gemfile
@@ -6,10 +6,13 @@
 
 source 'https://rubygems.org'
 
+gem 'opentelemetry-metrics-api', '~> 0.2.0'
+gem 'pry-byebug'
+
 gemspec
 
 group :test do
   gem 'opentelemetry-instrumentation-base', path: '../base'
   gem 'opentelemetry-instrumentation-redis', path: '../redis'
-  gem 'pry-byebug'
+  gem 'opentelemetry-metrics-sdk'
 end
diff --git a/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/instrumentation.rb b/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/instrumentation.rb
index 1f3fe27200..e61d56cf40 100644
--- a/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/instrumentation.rb
+++ b/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/instrumentation.rb
@@ -107,6 +107,15 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base
         option :trace_poller_wait,           default: false, validate: :boolean
         option :trace_processor_process_one, default: false, validate: :boolean
         option :peer_service,                default: nil,   validate: :string
+        option :metrics,                     default: false, validate: :boolean
+
+        counter 'messaging.client.sent.messages'
+        histogram 'messaging.client.operation.duration', unit: 's'
+        counter 'messaging.client.consumed.messages'
+        histogram 'messaging.process.duration', unit: 's'
+
+        # TODO: https://github.com/open-telemetry/semantic-conventions/pull/1812
+        gauge 'messaging.queue.latency', unit: 's'
 
         private
 
@@ -115,6 +124,7 @@ def gem_version
         end
 
         def require_dependencies
+          require_relative 'middlewares/common'
           require_relative 'middlewares/client/tracer_middleware'
           require_relative 'middlewares/server/tracer_middleware'
 
diff --git a/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/client/tracer_middleware.rb b/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/client/tracer_middleware.rb
index 039390a8f3..438fb5c1aa 100644
--- a/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/client/tracer_middleware.rb
+++ b/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/client/tracer_middleware.rb
@@ -4,6 +4,8 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+require_relative '../common'
+
 module OpenTelemetry
   module Instrumentation
     module Sidekiq
@@ -12,6 +14,7 @@ module Client
           # TracerMiddleware propagates context and instruments Sidekiq client
           # by way of its middleware system
           class TracerMiddleware
+            include Common
             include ::Sidekiq::ClientMiddleware if defined?(::Sidekiq::ClientMiddleware)
 
             def call(_worker_class, job, _queue, _redis_pool)
@@ -33,17 +36,49 @@ def call(_worker_class, job, _queue, _redis_pool)
                 OpenTelemetry.propagation.inject(job)
                 span.add_event('created_at', timestamp: job['created_at'])
                 yield
+              end.tap do # rubocop: disable Style/MultilineBlockChain
+                count_sent_message(job)
               end
             end
 
             private
 
-            def instrumentation_config
-              Sidekiq::Instrumentation.instance.config
+            def count_sent_message(job)
+              with_meter do |_meter|
+                counter_attributes = metrics_attributes(job).merge(
+                  {
+                    'messaging.operation.name' => 'create'
+                    # server.address => # FIXME: required if available
+                    # messaging.destination.partition.id => FIXME: recommended
+                    # server.port => # FIXME: recommended
+                  }
+                )
+
+                counter = messaging_client_sent_messages_counter
+                counter.add(1, attributes: counter_attributes)
+              end
+            end
+
+            def messaging_client_sent_messages_counter
+              instrumentation.counter('messaging.client.sent.messages')
             end
 
             def tracer
-              Sidekiq::Instrumentation.instance.tracer
+              instrumentation.tracer
+            end
+
+            def with_meter(&block)
+              instrumentation.with_meter(&block)
+            end
+
+            def metrics_attributes(job)
+              {
+                'messaging.system' => 'sidekiq', # FIXME: metrics semconv
+                'messaging.destination.name' => job['queue'] # FIXME: metrics semconv
+                # server.address => # FIXME: required if available
+                # messaging.destination.partition.id => FIXME: recommended
+                # server.port => # FIXME: recommended
+              }
             end
           end
         end
diff --git a/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/common.rb b/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/common.rb
new file mode 100644
index 0000000000..3076a9ee03
--- /dev/null
+++ b/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/common.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+# Copyright The OpenTelemetry Authors
+#
+# SPDX-License-Identifier: Apache-2.0
+
+module OpenTelemetry
+  module Instrumentation
+    module Sidekiq
+      module Middlewares
+        # Common logic for server and client middlewares
+        module Common
+          private
+
+          def instrumentation
+            Sidekiq::Instrumentation.instance
+          end
+
+          def instrumentation_config
+            Sidekiq::Instrumentation.instance.config
+          end
+
+          # Bypasses _all_ enclosed logic unless metrics are enabled
+          def with_meter(&block)
+            instrumentation.with_meter(&block)
+          end
+
+          # time an inner block and yield the duration to the given callback
+          def timed(callback)
+            return yield unless metrics_enabled?
+
+            t0 = monotonic_now
+
+            yield.tap do
+              callback.call(monotonic_now - t0)
+            end
+          end
+
+          def realtime_now
+            Process.clock_gettime(Process::CLOCK_REALTIME)
+          end
+
+          def monotonic_now
+            Process.clock_gettime(Process::CLOCK_MONOTONIC)
+          end
+
+          def tracer
+            instrumentation.tracer
+          end
+
+          def metrics_enabled?
+            instrumentation.metrics_enabled?
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/server/tracer_middleware.rb b/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/server/tracer_middleware.rb
index 90da96ea3f..f8d51ca702 100644
--- a/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/server/tracer_middleware.rb
+++ b/instrumentation/sidekiq/lib/opentelemetry/instrumentation/sidekiq/middlewares/server/tracer_middleware.rb
@@ -4,6 +4,8 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+require_relative '../common'
+
 module OpenTelemetry
   module Instrumentation
     module Sidekiq
@@ -12,6 +14,7 @@ module Server
           # TracerMiddleware propagates context and instruments Sidekiq requests
           # by way of its middleware system
           class TracerMiddleware
+            include Common
             include ::Sidekiq::ServerMiddleware if defined?(::Sidekiq::ServerMiddleware)
 
             def call(_worker, msg, _queue)
@@ -32,40 +35,91 @@ def call(_worker, msg, _queue)
 
               extracted_context = OpenTelemetry.propagation.extract(msg)
               OpenTelemetry::Context.with_current(extracted_context) do
-                if instrumentation_config[:propagation_style] == :child
-                  tracer.in_span(span_name, attributes: attributes, kind: :consumer) do |span|
-                    span.add_event('created_at', timestamp: msg['created_at'])
-                    span.add_event('enqueued_at', timestamp: msg['enqueued_at'])
-                    yield
-                  end
-                else
-                  links = []
-                  span_context = OpenTelemetry::Trace.current_span(extracted_context).context
-                  links << OpenTelemetry::Trace::Link.new(span_context) if instrumentation_config[:propagation_style] == :link && span_context.valid?
-                  span = tracer.start_root_span(span_name, attributes: attributes, links: links, kind: :consumer)
-                  OpenTelemetry::Trace.with_span(span) do
-                    span.add_event('created_at', timestamp: msg['created_at'])
-                    span.add_event('enqueued_at', timestamp: msg['enqueued_at'])
-                    yield
-                  rescue Exception => e # rubocop:disable Lint/RescueException
-                    span.record_exception(e)
-                    span.status = OpenTelemetry::Trace::Status.error("Unhandled exception of type: #{e.class}")
-                    raise e
-                  ensure
-                    span.finish
+                track_queue_latency(msg)
+
+                timed(track_process_time_callback(msg)) do
+                  if instrumentation_config[:propagation_style] == :child
+                    tracer.in_span(span_name, attributes: attributes, kind: :consumer) do |span|
+                      span.add_event('created_at', timestamp: msg['created_at'])
+                      span.add_event('enqueued_at', timestamp: msg['enqueued_at'])
+                      yield
+                    end
+                  else
+                    links = []
+                    span_context = OpenTelemetry::Trace.current_span(extracted_context).context
+                    links << OpenTelemetry::Trace::Link.new(span_context) if instrumentation_config[:propagation_style] == :link && span_context.valid?
+                    span = tracer.start_root_span(span_name, attributes: attributes, links: links, kind: :consumer)
+                    OpenTelemetry::Trace.with_span(span) do
+                      span.add_event('created_at', timestamp: msg['created_at'])
+                      span.add_event('enqueued_at', timestamp: msg['enqueued_at'])
+                      yield
+                    rescue Exception => e # rubocop:disable Lint/RescueException
+                      span.record_exception(e)
+                      span.status = OpenTelemetry::Trace::Status.error("Unhandled exception of type: #{e.class}")
+                      raise e
+                    ensure
+                      span.finish
+                    end
                   end
                 end
+
+                count_consumed_message(msg)
               end
             end
 
             private
 
-            def instrumentation_config
-              Sidekiq::Instrumentation.instance.config
+            def track_queue_latency(msg)
+              with_meter do
+                return unless (enqueued_at = msg['enqueued_at'])
+                return unless enqueued_at.is_a?(Numeric)
+
+                latency = (realtime_now - enqueued_at).abs
+
+                queue_latency_gauge&.record(latency, attributes: metrics_attributes(msg))
+              end
+            end
+
+            def track_process_time_callback(msg)
+              ->(duration) { track_process_time(msg, duration) }
+            end
+
+            def track_process_time(msg, duration)
+              with_meter do
+                attributes = metrics_attributes(msg).merge(
+                  { 'messaging.operation.name' => 'process' }
+                )
+                messaging_process_duration_histogram&.record(duration, attributes: attributes)
+              end
+            end
+
+            def messaging_process_duration_histogram
+              instrumentation.histogram('messaging.process.duration')
+            end
+
+            def count_consumed_message(msg)
+              with_meter do
+                messaging_client_consumed_messages_counter.add(1, attributes: metrics_attributes(msg))
+              end
             end
 
-            def tracer
-              Sidekiq::Instrumentation.instance.tracer
+            def messaging_client_consumed_messages_counter
+              instrumentation.counter('messaging.client.consumed.messages')
+            end
+
+            def queue_latency_gauge
+              instrumentation.gauge('messaging.queue.latency')
+            end
+
+            # FIXME: dedupe
+            def metrics_attributes(msg)
+              {
+                'messaging.system' => 'sidekiq', # FIXME: metrics semconv
+                'messaging.destination.name' => msg['queue'] # FIXME: metrics semconv
+                # server.address => # FIXME: required if available
+                # messaging.destination.partition.id => FIXME: recommended
+                # server.port => # FIXME: recommended
+              }
             end
           end
         end
diff --git a/instrumentation/sidekiq/opentelemetry-instrumentation-sidekiq.gemspec b/instrumentation/sidekiq/opentelemetry-instrumentation-sidekiq.gemspec
index fc8b65bdff..a4cd50f169 100644
--- a/instrumentation/sidekiq/opentelemetry-instrumentation-sidekiq.gemspec
+++ b/instrumentation/sidekiq/opentelemetry-instrumentation-sidekiq.gemspec
@@ -32,6 +32,7 @@ Gem::Specification.new do |spec|
   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 'minitest-reporters'
   spec.add_development_dependency 'opentelemetry-sdk', '~> 1.1'
   spec.add_development_dependency 'opentelemetry-test-helpers', '~> 0.3'
   spec.add_development_dependency 'rspec-mocks'
diff --git a/instrumentation/sidekiq/test/opentelemetry/instrumentation/sidekiq/middlewares/client/tracer_middleware_test.rb b/instrumentation/sidekiq/test/opentelemetry/instrumentation/sidekiq/middlewares/client/tracer_middleware_test.rb
index a2de3d05d3..25a5e6b2fd 100644
--- a/instrumentation/sidekiq/test/opentelemetry/instrumentation/sidekiq/middlewares/client/tracer_middleware_test.rb
+++ b/instrumentation/sidekiq/test/opentelemetry/instrumentation/sidekiq/middlewares/client/tracer_middleware_test.rb
@@ -14,10 +14,20 @@
   let(:spans) { exporter.finished_spans }
   let(:enqueue_span) { spans.first }
   let(:config) { {} }
+  let(:metrics_exporter) { METRICS_EXPORTER }
+
+  with_metrics_sdk do
+    let(:metric_snapshots) do
+      METRICS_EXPORTER.tap(&:pull)
+                      .metric_snapshots.select { |snapshot| snapshot.data_points.any? }
+                      .group_by(&:name)
+    end
+  end
 
   before do
     instrumentation.install(config)
     exporter.reset
+    reset_metrics_exporter
   end
 
   after do
@@ -81,5 +91,34 @@
         _(enqueue_span.attributes['peer.service']).must_equal 'MySidekiqService'
       end
     end
+
+    with_metrics_sdk do
+      it 'yields no metrics if config is not set' do
+        _(instrumentation.metrics_enabled?).must_equal false
+        SimpleJob.perform_async
+        SimpleJob.drain
+
+        _(metric_snapshots).must_be_empty
+      end
+
+      describe 'with metrics enabled' do
+        let(:config) { { metrics: true } }
+
+        it 'metrics processing' do
+          _(instrumentation.metrics_enabled?).must_equal true
+          SimpleJob.perform_async
+          SimpleJob.drain
+
+          sent_messages = metric_snapshots['messaging.client.sent.messages']
+          _(sent_messages.count).must_equal 1
+          _(sent_messages.first.data_points.count).must_equal 1
+          _(sent_messages.first.data_points.first.value).must_equal 1
+          sent_messages_attributes = sent_messages.first.data_points.first.attributes
+          _(sent_messages_attributes['messaging.system']).must_equal 'sidekiq'
+          _(sent_messages_attributes['messaging.destination.name']).must_equal 'default' # FIXME: newer semconv specifies this key
+          _(sent_messages_attributes['messaging.operation.name']).must_equal 'create'
+        end
+      end
+    end
   end
 end
diff --git a/instrumentation/sidekiq/test/opentelemetry/instrumentation/sidekiq/middlewares/server/tracer_middleware_test.rb b/instrumentation/sidekiq/test/opentelemetry/instrumentation/sidekiq/middlewares/server/tracer_middleware_test.rb
index 145d3b7438..f05ce4a4e0 100644
--- a/instrumentation/sidekiq/test/opentelemetry/instrumentation/sidekiq/middlewares/server/tracer_middleware_test.rb
+++ b/instrumentation/sidekiq/test/opentelemetry/instrumentation/sidekiq/middlewares/server/tracer_middleware_test.rb
@@ -17,12 +17,23 @@
   let(:root_span) { spans.find { |s| s.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID } }
   let(:config) { {} }
 
+  with_metrics_sdk do
+    let(:metric_snapshots) do
+      METRICS_EXPORTER.tap(&:pull)
+                      .metric_snapshots.select { |snapshot| snapshot.data_points.any? }
+                      .group_by(&:name)
+    end
+  end
+
   before do
     instrumentation.install(config)
     exporter.reset
+    reset_metrics_exporter
   end
 
-  after { instrumentation.instance_variable_set(:@installed, false) }
+  after do
+    instrumentation.instance_variable_set(:@installed, false)
+  end
 
   describe 'enqueue spans' do
     it 'before performing any jobs' do
@@ -49,6 +60,54 @@
       _(job_span.events[1].name).must_equal('enqueued_at')
     end
 
+    with_metrics_sdk do
+      # FIXME: still seeing order-dependent failure here
+      it 'yields no metrics if config is not set' do
+        _(OpenTelemetry::Instrumentation::Sidekiq::Instrumentation.instance.metrics_enabled?).must_equal false
+        SimpleJob.perform_async
+        SimpleJob.drain
+
+        _(exporter.finished_spans.size).must_equal 2
+        _(metric_snapshots).must_be_empty
+      end
+
+      describe 'with metrics enabled' do
+        let(:config) { { metrics: true } }
+
+        it 'metrics processing' do
+          _(instrumentation.metrics_enabled?).must_equal true
+          SimpleJob.perform_async
+          SimpleJob.drain
+
+          queue_latency = metric_snapshots['messaging.queue.latency']
+          _(queue_latency.count).must_equal 1
+          _(queue_latency.first.data_points.count).must_equal 1
+          queue_latency_attributes = queue_latency.first.data_points.first.attributes
+          _(queue_latency_attributes['messaging.system']).must_equal 'sidekiq'
+          _(queue_latency_attributes['messaging.destination.name']).must_equal 'default' # FIXME: newer semconv specifies this key
+
+          process_duration = metric_snapshots['messaging.process.duration']
+          _(process_duration.count).must_equal 1
+          _(process_duration.first.data_points.count).must_equal 1
+          process_duration_attributes = process_duration.first.data_points.first.attributes
+          _(process_duration_attributes['messaging.system']).must_equal 'sidekiq'
+          _(process_duration_attributes['messaging.operation.name']).must_equal 'process'
+          _(process_duration_attributes['messaging.destination.name']).must_equal 'default'
+
+          process_duration_data_point = process_duration.first.data_points.first
+          _(process_duration_data_point.count).must_equal 1
+
+          consumed_messages = metric_snapshots['messaging.client.consumed.messages']
+          _(consumed_messages.count).must_equal 1
+          _(consumed_messages.first.data_points.count).must_equal 1
+          consumed_messages_attributes = queue_latency.first.data_points.first.attributes
+          _(consumed_messages_attributes['messaging.system']).must_equal 'sidekiq'
+          _(consumed_messages_attributes['messaging.destination.name']).must_equal 'default' # FIXME: newer semconv specifies this key
+          _(consumed_messages.first.data_points.first.value).must_equal 1
+        end
+      end
+    end
+
     it 'traces when enqueued with Active Job' do
       SimpleJobWithActiveJob.perform_later(1, 2)
       Sidekiq::Worker.drain_all
diff --git a/instrumentation/sidekiq/test/test_helper.rb b/instrumentation/sidekiq/test/test_helper.rb
index df49e41255..154064701c 100644
--- a/instrumentation/sidekiq/test/test_helper.rb
+++ b/instrumentation/sidekiq/test/test_helper.rb
@@ -10,9 +10,12 @@
 require 'active_job'
 
 require 'minitest/autorun'
+require 'minitest/reporters'
 require 'rspec/mocks/minitest_integration'
 require 'sidekiq/testing'
 
+Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new
+
 if Gem::Version.new(Sidekiq::VERSION) >= Gem::Version.new('7.0.0')
   require 'helpers/mock_loader_for_7.0'
 elsif Gem::Version.new(Sidekiq::VERSION) >= Gem::Version.new('6.5.0')
@@ -21,6 +24,14 @@
   require 'helpers/mock_loader'
 end
 
+# speed up tests that rely on empty queues
+Sidekiq::BasicFetch::TIMEOUT = if Gem.loaded_specs['sidekiq'].version < Gem::Version.new('6.5.0')
+                                 # Redis 4.8 has trouble with float timeouts given as positional arguments
+                                 1
+                               else
+                                 0.1
+                               end
+
 # OpenTelemetry SDK config for testing
 EXPORTER = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
 span_processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(EXPORTER)
@@ -30,6 +41,74 @@
   c.add_span_processor span_processor
 end
 
+module LoadedMetricsFeatures
+  OTEL_METRICS_API_LOADED = !Gem.loaded_specs['opentelemetry-metrics-api'].nil?
+  OTEL_METRICS_SDK_LOADED = !Gem.loaded_specs['opentelemetry-metrics-sdk'].nil?
+
+  extend self
+
+  def api_loaded?
+    OTEL_METRICS_API_LOADED
+  end
+
+  def sdk_loaded?
+    OTEL_METRICS_SDK_LOADED
+  end
+end
+
+# NOTE: this isn't currently used, but it may be useful to fully reset state between tests
+def reset_meter_provider
+  return unless LoadedMetricsFeatures.sdk_loaded?
+
+  resource = OpenTelemetry.meter_provider.resource
+  OpenTelemetry.meter_provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(resource: resource)
+  OpenTelemetry.meter_provider.add_metric_reader(METRICS_EXPORTER)
+end
+
+def reset_metrics_exporter
+  return unless LoadedMetricsFeatures.sdk_loaded?
+
+  METRICS_EXPORTER.pull
+  METRICS_EXPORTER.reset
+end
+
+if LoadedMetricsFeatures.sdk_loaded?
+  METRICS_EXPORTER = OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new
+  OpenTelemetry.meter_provider.add_metric_reader(METRICS_EXPORTER)
+end
+
+module ConditionalEvaluation
+  def self.included(base)
+    base.extend(self)
+  end
+
+  def self.prepended(base)
+    base.extend(self)
+  end
+
+  def with_metrics_sdk
+    yield if LoadedMetricsFeatures.sdk_loaded?
+  end
+
+  # FIXME: unclear if this is ever needed
+  def without_metrics_sdk
+    yield unless LoadedMetricsFeatures.sdk_loaded?
+  end
+
+  def it(desc = 'anonymous', with_metrics_sdk: false, without_metrics_sdk: false, &block)
+    return super(desc, &block) unless with_metrics_sdk || without_metrics_sdk
+
+    raise ArgumentError, 'without_metrics_sdk and with_metrics_sdk must be mutually exclusive' if without_metrics_sdk && with_metrics_sdk
+
+    return if with_metrics_sdk && !LoadedMetricsFeatures.sdk_loaded?
+    return if without_metrics_sdk && LoadedMetricsFeatures.sdk_loaded?
+
+    super(desc, &block)
+  end
+end
+
+Minitest::Spec.prepend(ConditionalEvaluation)
+
 # Sidekiq redis configuration
 ENV['TEST_REDIS_HOST'] ||= '127.0.0.1'
 ENV['TEST_REDIS_PORT'] ||= '16379'