diff --git a/lib/ddtrace/contrib/active_job/configuration/settings.rb b/lib/ddtrace/contrib/active_job/configuration/settings.rb new file mode 100644 index 0000000000..59752d7793 --- /dev/null +++ b/lib/ddtrace/contrib/active_job/configuration/settings.rb @@ -0,0 +1,32 @@ +# typed: false +require 'ddtrace/contrib/configuration/settings' +require 'ddtrace/contrib/active_job/ext' + +module Datadog + module Contrib + module ActiveJob + module Configuration + # Custom settings for the DelayedJob integration + class Settings < Contrib::Configuration::Settings + option :enabled do |o| + o.default { env_to_bool(Ext::ENV_ENABLED, true) } + o.lazy + end + + option :analytics_enabled do |o| + o.default { env_to_bool([Ext::ENV_ANALYTICS_ENABLED, Ext::ENV_ANALYTICS_ENABLED_OLD], false) } + o.lazy + end + + option :analytics_sample_rate do |o| + o.default { env_to_float([Ext::ENV_ANALYTICS_SAMPLE_RATE, Ext::ENV_ANALYTICS_SAMPLE_RATE_OLD], 1.0) } + o.lazy + end + + option :service_name, default: Ext::SERVICE_NAME + option :error_handler, default: Datadog::Tracer::DEFAULT_ON_ERROR + end + end + end + end +end diff --git a/lib/ddtrace/contrib/active_job/event.rb b/lib/ddtrace/contrib/active_job/event.rb new file mode 100644 index 0000000000..098f900e14 --- /dev/null +++ b/lib/ddtrace/contrib/active_job/event.rb @@ -0,0 +1,31 @@ +# typed: true +require 'ddtrace/contrib/active_support/notifications/event' + +module Datadog + module Contrib + module ActiveJob + # Defines basic behaviors for an ActiveJob event. + module Event + def self.included(base) + base.include(ActiveSupport::Notifications::Event) + base.extend(ClassMethods) + end + + # Class methods for ActiveJob events. + module ClassMethods + def span_options + { service: configuration[:service_name] } + end + + def tracer + -> { configuration[:tracer] } + end + + def configuration + Datadog.configuration[:active_job] + end + end + end + end + end +end diff --git a/lib/ddtrace/contrib/active_job/events.rb b/lib/ddtrace/contrib/active_job/events.rb new file mode 100644 index 0000000000..6bfe24fbc5 --- /dev/null +++ b/lib/ddtrace/contrib/active_job/events.rb @@ -0,0 +1,29 @@ +# typed: false +require 'ddtrace/contrib/active_job/events/perform' + +module Datadog + module Contrib + module ActiveJob + # Defines collection of instrumented ActiveJob events + module Events + ALL = [ + Events::Perform, + ].freeze + + module_function + + def all + self::ALL + end + + def subscriptions + all.collect(&:subscriptions).collect(&:to_a).flatten + end + + def subscribe! + all.each(&:subscribe!) + end + end + end + end +end diff --git a/lib/ddtrace/contrib/active_job/events/perform.rb b/lib/ddtrace/contrib/active_job/events/perform.rb new file mode 100644 index 0000000000..20c6ba8a6d --- /dev/null +++ b/lib/ddtrace/contrib/active_job/events/perform.rb @@ -0,0 +1,55 @@ +# typed: false +require 'ddtrace/ext/integration' +require 'ddtrace/contrib/analytics' +require 'ddtrace/contrib/active_job/ext' +require 'ddtrace/contrib/active_job/event' + +module Datadog + module Contrib + module ActiveJob + module Events + # Defines instrumentation for perform.active_job event + module Perform + include ActiveJob::Event + + EVENT_NAME = 'perform.active_job'.freeze + + module_function + + def event_name + self::EVENT_NAME + end + + def span_name + Ext::SPAN_PERFORM + end + + def process(span, event, _id, payload) + span.name = span_name + span.service = configuration[:service_name] + span.resource = payload[:job].class.name + + adapter_name = if payload[:adapter].is_a?(Class) + payload[:adapter].name + else + payload[:adapter].class.name + end + span.set_tag(Ext::TAG_ADAPTER, adapter_name) + + # Set analytics sample rate + if Contrib::Analytics.enabled?(configuration[:analytics_enabled]) + Contrib::Analytics.set_sample_rate(span, configuration[:analytics_sample_rate]) + end + + span.set_tag(Ext::TAG_JOB_ID, payload[:job].job_id) + span.set_tag(Ext::TAG_JOB_QUEUE, payload[:job].queue_name) + + span.set_tag(Ext::TAG_JOB_PRIORITY, payload[:job].priority) if payload[:job].respond_to?(:priority) + rescue StandardError => e + Datadog.logger.debug(e.message) + end + end + end + end + end +end diff --git a/lib/ddtrace/contrib/active_job/ext.rb b/lib/ddtrace/contrib/active_job/ext.rb new file mode 100644 index 0000000000..3751e7c114 --- /dev/null +++ b/lib/ddtrace/contrib/active_job/ext.rb @@ -0,0 +1,24 @@ +# typed: true +module Datadog + module Contrib + module ActiveJob + module Ext + APP = 'active_job'.freeze + SERVICE_NAME = 'active_job'.freeze + + ENV_ENABLED = 'DD_TRACE_ACTIVE_JOB_ENABLED'.freeze + ENV_ANALYTICS_ENABLED = 'DD_TRACE_ACTIVE_JOB_ANALYTICS_ENABLED'.freeze + ENV_ANALYTICS_ENABLED_OLD = 'DD_ACTIVE_JOB_ANALYTICS_ENABLED'.freeze + ENV_ANALYTICS_SAMPLE_RATE = 'DD_TRACE_ACTIVE_JOB_ANALYTICS_SAMPLE_RATE'.freeze + ENV_ANALYTICS_SAMPLE_RATE_OLD = 'DD_ACTIVE_JOB_ANALYTICS_SAMPLE_RATE'.freeze + + SPAN_PERFORM = 'active_job.perform'.freeze + + TAG_ADAPTER = 'active_job.adapter'.freeze + TAG_JOB_ID = 'active_job.job.id'.freeze + TAG_JOB_QUEUE = 'active_job.job.queue'.freeze + TAG_JOB_PRIORITY = 'active_job.job.priority'.freeze + end + end + end +end diff --git a/lib/ddtrace/contrib/active_job/integration.rb b/lib/ddtrace/contrib/active_job/integration.rb new file mode 100644 index 0000000000..e4483f7408 --- /dev/null +++ b/lib/ddtrace/contrib/active_job/integration.rb @@ -0,0 +1,46 @@ +# typed: false +require 'ddtrace/contrib/integration' +require 'ddtrace/contrib/active_job/configuration/settings' +require 'ddtrace/contrib/active_job/patcher' +require 'ddtrace/contrib/rails/utils' + +module Datadog + module Contrib + module ActiveJob + # Describes the ActiveJob integration + class Integration + include Contrib::Integration + + MINIMUM_VERSION = Gem::Version.new('4.2') + + register_as :active_job, auto_patch: false + + def self.version + Gem.loaded_specs['activejob'] && Gem.loaded_specs['activejob'].version + end + + def self.loaded? + !defined?(::ActiveJob).nil? + end + + def self.compatible? + super && version >= MINIMUM_VERSION + end + + # enabled by rails integration so should only auto instrument + # if detected that it is being used without rails + def auto_instrument? + !Datadog::Contrib::Rails::Utils.railtie_supported? + end + + def default_configuration + Configuration::Settings.new + end + + def patcher + ActiveJob::Patcher + end + end + end + end +end diff --git a/lib/ddtrace/contrib/active_job/patcher.rb b/lib/ddtrace/contrib/active_job/patcher.rb new file mode 100644 index 0000000000..a40234b786 --- /dev/null +++ b/lib/ddtrace/contrib/active_job/patcher.rb @@ -0,0 +1,25 @@ +# typed: true +require 'ddtrace/contrib/patcher' +require 'ddtrace/contrib/active_job/ext' +require 'ddtrace/contrib/active_job/events' + +module Datadog + module Contrib + module ActiveJob + # Patcher enables patching of 'active_job' module. + module Patcher + include Contrib::Patcher + + module_function + + def target_version + Integration.version + end + + def patch + Events.subscribe! + end + end + end + end +end diff --git a/spec/ddtrace/contrib/active_job/integration_spec.rb b/spec/ddtrace/contrib/active_job/integration_spec.rb new file mode 100644 index 0000000000..0ebad5fd38 --- /dev/null +++ b/spec/ddtrace/contrib/active_job/integration_spec.rb @@ -0,0 +1,78 @@ +# typed: ignore +require 'ddtrace/contrib/support/spec_helper' +require 'ddtrace/contrib/auto_instrument_examples' + +require 'ddtrace/contrib/active_job/integration' + +RSpec.describe Datadog::Contrib::ActiveJob::Integration do + extend ConfigurationHelpers + + let(:integration) { described_class.new(:active_job) } + + describe '.version' do + subject(:version) { described_class.version } + + context 'when the "activejob" gem is loaded' do + include_context 'loaded gems', activejob: described_class::MINIMUM_VERSION + it { is_expected.to be_a_kind_of(Gem::Version) } + end + + context 'when "activejob" gem is not loaded' do + include_context 'loaded gems', activejob: nil + it { is_expected.to be nil } + end + end + + describe '.loaded?' do + subject(:loaded?) { described_class.loaded? } + + context 'when ActiveJob is defined' do + before { stub_const('ActiveJob', Class.new) } + + it { is_expected.to be true } + end + + context 'when ActiveJob is not defined' do + before { hide_const('ActiveJob') } + + it { is_expected.to be false } + end + end + + describe '.compatible?' do + subject(:compatible?) { described_class.compatible? } + + context 'when "activejob" gem is loaded with a version' do + context 'that is less than the minimum' do + include_context 'loaded gems', activejob: decrement_gem_version(described_class::MINIMUM_VERSION) + it { is_expected.to be false } + end + + context 'that meets the minimum version' do + include_context 'loaded gems', activejob: described_class::MINIMUM_VERSION + it { is_expected.to be true } + end + end + + context 'when gem is not loaded' do + include_context 'loaded gems', actionpack: nil, activejob: nil + it { is_expected.to be false } + end + end + + describe '#auto_instrument?' do + it_behaves_like 'rails sub-gem auto_instrument?' + end + + describe '#default_configuration' do + subject(:default_configuration) { integration.default_configuration } + + it { is_expected.to be_a_kind_of(Datadog::Contrib::ActiveJob::Configuration::Settings) } + end + + describe '#patcher' do + subject(:patcher) { integration.patcher } + + it { is_expected.to be Datadog::Contrib::ActiveJob::Patcher } + end +end diff --git a/spec/ddtrace/contrib/rails/rails_active_job_spec.rb b/spec/ddtrace/contrib/rails/rails_active_job_spec.rb index c684a58adc..cb094a34e7 100644 --- a/spec/ddtrace/contrib/rails/rails_active_job_spec.rb +++ b/spec/ddtrace/contrib/rails/rails_active_job_spec.rb @@ -2,8 +2,13 @@ # This module tests the right integration between Sidekiq and # Rails. Functionality tests for Rails and Sidekiq must go # in their testing modules. -require 'sidekiq/testing' -require 'ddtrace/contrib/sidekiq/server_tracer' +begin + require 'sidekiq/testing' + require 'ddtrace/contrib/sidekiq/server_tracer' +rescue LoadError + puts 'Sidekiq testing harness not loaded' +end + begin require 'active_job' rescue LoadError @@ -11,68 +16,109 @@ end require 'ddtrace/contrib/rails/rails_helper' +require 'ddtrace/contrib/active_job/integration' -RSpec.describe 'Rails with Sidekiq' do +RSpec.describe 'ActiveJob' do before { skip unless defined? ::ActiveJob } - include_context 'Rails test application' - before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with('USE_SIDEKIQ').and_return('true') - end + context 'with active_job instrumentation' do + before do + Datadog.configure do |c| + c.use :active_job + end - before do - Sidekiq.configure_client do |config| - config.redis = { url: ENV['REDIS_URL'] } + # initialize the application + app end - Sidekiq.configure_server do |config| - config.redis = { url: ENV['REDIS_URL'] } - end + after do + Datadog.registry[:active_job].reset_configuration! + Datadog.configuration.reset! - Sidekiq::Testing.inline! - end - - before { app } + # unsubscribe the ActiveSupport notifications + ActiveSupport::Notifications.unsubscribe('perform.active_job') + end - context 'with a Sidekiq::Worker' do subject(:worker) do - stub_const('EmptyWorker', Class.new do - include Sidekiq::Worker - + stub_const('EmptyJob', Class.new(ActiveJob::Base) do def perform; end end) end - it 'has correct Sidekiq span' do - worker.perform_async + it 'has correct ActiveJob span' do + worker.set(queue: :elephants, priority: -10).perform_later - expect(span.name).to eq('sidekiq.job') - expect(span.resource).to eq('EmptyWorker') - expect(span.get_tag('sidekiq.job.wrapper')).to be_nil - expect(span.get_tag('sidekiq.job.id')).to match(/[0-9a-f]{24}/) - expect(span.get_tag('sidekiq.job.retry')).to eq('true') - expect(span.get_tag('sidekiq.job.queue')).to eq('default') + expect(span.name).to eq('active_job.perform') + expect(span.resource).to eq('EmptyJob') + expect(span.get_tag('active_job.adapter')).to eq('ActiveJob::QueueAdapters::InlineAdapter') + expect(span.get_tag('active_job.job.id')).to match(/[0-9a-f\-]{32}/) + expect(span.get_tag('active_job.job.queue')).to eq('elephants') + + if Datadog::Contrib::ActiveJob::Integration.version >= Gem::Version.new('5.0') + expect(span.get_tag('active_job.job.priority')).to eq(-10) + end end end - context 'with an ActiveJob' do - subject(:worker) do - stub_const('EmptyJob', Class.new(ActiveJob::Base) do - def perform; end - end) + context 'with Sidekiq instrumentation' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('USE_SIDEKIQ').and_return('true') end - it 'has correct Sidekiq span' do - worker.perform_later + before do + Sidekiq.configure_client do |config| + config.redis = { url: ENV['REDIS_URL'] } + end - expect(span.name).to eq('sidekiq.job') - expect(span.resource).to eq('EmptyJob') - expect(span.get_tag('sidekiq.job.wrapper')).to eq('ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper') - expect(span.get_tag('sidekiq.job.id')).to match(/[0-9a-f]{24}/) - expect(span.get_tag('sidekiq.job.retry')).to eq('true') - expect(span.get_tag('sidekiq.job.queue')).to eq('default') + Sidekiq.configure_server do |config| + config.redis = { url: ENV['REDIS_URL'] } + end + + Sidekiq::Testing.inline! + end + + before { app } + + context 'with a Sidekiq::Worker' do + subject(:worker) do + stub_const('EmptyWorker', Class.new do + include Sidekiq::Worker + + def perform; end + end) + end + + it 'has correct Sidekiq span' do + worker.perform_async + + expect(span.name).to eq('sidekiq.job') + expect(span.resource).to eq('EmptyWorker') + expect(span.get_tag('sidekiq.job.wrapper')).to be_nil + expect(span.get_tag('sidekiq.job.id')).to match(/[0-9a-f]{24}/) + expect(span.get_tag('sidekiq.job.retry')).to eq('true') + expect(span.get_tag('sidekiq.job.queue')).to eq('default') + end + end + + context 'with an ActiveJob' do + subject(:worker) do + stub_const('EmptyJob', Class.new(ActiveJob::Base) do + def perform; end + end) + end + + it 'has correct Sidekiq span' do + worker.perform_later + + expect(span.name).to eq('sidekiq.job') + expect(span.resource).to eq('EmptyJob') + expect(span.get_tag('sidekiq.job.wrapper')).to eq('ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper') + expect(span.get_tag('sidekiq.job.id')).to match(/[0-9a-f]{24}/) + expect(span.get_tag('sidekiq.job.retry')).to eq('true') + expect(span.get_tag('sidekiq.job.queue')).to eq('default') + end end end end diff --git a/spec/ddtrace/contrib/rails/support/rails4.rb b/spec/ddtrace/contrib/rails/support/rails4.rb index cadf435d76..4d25d7f363 100644 --- a/spec/ddtrace/contrib/rails/support/rails4.rb +++ b/spec/ddtrace/contrib/rails/support/rails4.rb @@ -41,6 +41,7 @@ def config.database_configuration config.middleware.delete ActionDispatch::DebugExceptions instance_eval(&during_init) + config.active_job.queue_adapter = :inline if ENV['USE_SIDEKIQ'] config.active_job.queue_adapter = :sidekiq # add Sidekiq middleware @@ -68,7 +69,11 @@ def config.database_configuration end end - Rails.application.config.active_job.queue_adapter = :sidekiq + Rails.application.config.active_job.queue_adapter = if ENV['USE_SIDEKIQ'] + :sidekiq + else + :inline + end before_test_init.call initialize! diff --git a/spec/ddtrace/contrib/rails/support/rails5.rb b/spec/ddtrace/contrib/rails/support/rails5.rb index 9bef901a7c..29574b061d 100644 --- a/spec/ddtrace/contrib/rails/support/rails5.rb +++ b/spec/ddtrace/contrib/rails/support/rails5.rb @@ -39,6 +39,7 @@ def config.database_configuration config.middleware.delete ActionDispatch::DebugExceptions instance_eval(&during_init) + config.active_job.queue_adapter = :inline if ENV['USE_SIDEKIQ'] config.active_job.queue_adapter = :sidekiq # add Sidekiq middleware @@ -66,7 +67,11 @@ def config.database_configuration end end - Rails.application.config.active_job.queue_adapter = :sidekiq + Rails.application.config.active_job.queue_adapter = if ENV['USE_SIDEKIQ'] + :sidekiq + else + :inline + end before_test_init.call initialize! diff --git a/spec/ddtrace/contrib/rails/support/rails6.rb b/spec/ddtrace/contrib/rails/support/rails6.rb index 9bbef71a83..9f7c6a9563 100644 --- a/spec/ddtrace/contrib/rails/support/rails6.rb +++ b/spec/ddtrace/contrib/rails/support/rails6.rb @@ -43,6 +43,7 @@ def config.database_configuration instance_eval(&during_init) + config.active_job.queue_adapter = :inline if ENV['USE_SIDEKIQ'] config.active_job.queue_adapter = :sidekiq # add Sidekiq middleware @@ -70,7 +71,11 @@ def config.database_configuration end end - Rails.application.config.active_job.queue_adapter = :sidekiq if ENV['USE_SIDEKIQ'] + Rails.application.config.active_job.queue_adapter = if ENV['USE_SIDEKIQ'] + :sidekiq + else + :inline + end Rails.application.config.file_watcher = Class.new(ActiveSupport::FileUpdateChecker) do # When running in full application mode, Rails tries to monitor