From 446ce285dfe2c1a6e54e9adbdb59701b66e77234 Mon Sep 17 00:00:00 2001 From: lairtonmendes Date: Sun, 7 Jun 2026 12:27:49 -0300 Subject: [PATCH] Add support for Solid Queue --- Gemfile | 11 ++ docs/reference/supported-technologies.md | 1 + docs/release-notes/index.md | 5 + lib/elastic_apm/config.rb | 1 + lib/elastic_apm/spies/solid_queue.rb | 80 +++++++++++ .../transport/connection/proxy_pipe.rb | 10 +- spec/elastic_apm/spies/solid_queue_spec.rb | 131 ++++++++++++++++++ 7 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 lib/elastic_apm/spies/solid_queue.rb create mode 100644 spec/elastic_apm/spies/solid_queue_spec.rb diff --git a/Gemfile b/Gemfile index 3ed59f57d..803daba90 100644 --- a/Gemfile +++ b/Gemfile @@ -136,6 +136,17 @@ else gem 'shoryuken', require: nil end +# Solid Queue requires Rails 7.1+ (Active Record + Active Job). +if frameworks_versions.key?('rails') + rails_version = frameworks_versions['rails'] + solid_queue_compatible = + rails_version == 'main' || + rails_version.to_s.empty? || + (rails_version =~ /\A\d+(\.\d+)?\z/ && + Gem::Version.new(rails_version) >= Gem::Version.new('7.1')) + gem 'solid_queue', require: nil if solid_queue_compatible +end + if RUBY_PLATFORM == 'java' # See issue #6547 in the JRuby repo. It is fixed in JRuby 9.3 gem 'i18n', '< 1.8.8' if JRUBY_VERSION < '9.3' diff --git a/docs/reference/supported-technologies.md b/docs/reference/supported-technologies.md index 9957a6124..1770c8440 100644 --- a/docs/reference/supported-technologies.md +++ b/docs/reference/supported-technologies.md @@ -80,6 +80,7 @@ We automatically instrument background processing using: * Sneakers (2.12.0) (Experimental, see [#676](https://github.com/elastic/apm-agent-ruby/pull/676)) * Resque (>= 2.0.0 <= 2.7.0) * SuckerPunch (>= 2.0.0 <= 3.3.0) +* Solid Queue (>= 1.0.0) ## Resque [supported-technologies-resque] diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 60c1e207d..cb507029d 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -28,6 +28,11 @@ All notable changes to this project will be documented here. This project adhere % ### Fixes [elastic-apm-ruby-agent-versionext-fixes] +## X.X.X [elastic-apm-ruby-agent-XXX-release-notes] + +### Features and enhancements [elastic-apm-ruby-agent-XXX-features-enhancements] +* Support Solid Queue [#1623](https://github.com/elastic/apm-agent-ruby/pull/1623) + ## 4.8.0 [elastic-apm-ruby-agent-480-release-notes] ### Features and enhancements [elastic-apm-ruby-agent-480-features-enhancements] diff --git a/lib/elastic_apm/config.rb b/lib/elastic_apm/config.rb index 3c03b7fa6..aa6fdea41 100644 --- a/lib/elastic_apm/config.rb +++ b/lib/elastic_apm/config.rb @@ -171,6 +171,7 @@ def available_instrumentations sinatra sneakers sns + solid_queue sqs sucker_punch tilt diff --git a/lib/elastic_apm/spies/solid_queue.rb b/lib/elastic_apm/spies/solid_queue.rb new file mode 100644 index 000000000..4de5d52a8 --- /dev/null +++ b/lib/elastic_apm/spies/solid_queue.rb @@ -0,0 +1,80 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# frozen_string_literal: true + +module ElasticAPM + # @api private + module Spies + # @api private + class SolidQueueSpy + TYPE = 'SolidQueue' + + # @api private + module Ext + def perform + name = job&.class_name + transaction = ElasticAPM.start_transaction(name, TYPE) + ElasticAPM.set_label(:queue, job.queue_name) if job&.queue_name + + super + + transaction&.done 'success' + transaction&.outcome = Transaction::Outcome::SUCCESS + rescue ::Exception => e + ElasticAPM.report(e, handled: false) + transaction&.done 'error' + transaction&.outcome = Transaction::Outcome::FAILURE + raise + ensure + ElasticAPM.end_transaction + end + end + + def install + # +SolidQueue::ClaimedExecution+ lives under +app/models+ and is + # autoloaded via Zeitwerk by the Rails engine. + # + # Two hooks are needed: + # - +after_initialize+ fires after eager loading in production, which + # is when +ClaimedExecution+ is first defined. +to_prepare+ alone is + # not enough because Rails runs +to_prepare+ *before* eager loading. + # - +to_prepare+ handles code reloads in development so the patch + # survives class unloading between reloads. + # + # Fork mode (the default solid_queue supervisor) is handled by the agent + # itself via +Agent#detect_forking!+ on each +start_transaction+, so no + # extra lifecycle hook wiring is needed here. + if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application + ::Rails.application.config.after_initialize { SolidQueueSpy.prepend_ext } + ::Rails.application.reloader.to_prepare { SolidQueueSpy.prepend_ext } + end + + SolidQueueSpy.prepend_ext + end + + def self.prepend_ext + return unless defined?(::SolidQueue::ClaimedExecution) + return if ::SolidQueue::ClaimedExecution.include?(Ext) + + ::SolidQueue::ClaimedExecution.prepend(Ext) + end + end + + register 'SolidQueue', 'solid_queue', SolidQueueSpy.new + end +end diff --git a/lib/elastic_apm/transport/connection/proxy_pipe.rb b/lib/elastic_apm/transport/connection/proxy_pipe.rb index d4a504238..dd13c31af 100644 --- a/lib/elastic_apm/transport/connection/proxy_pipe.rb +++ b/lib/elastic_apm/transport/connection/proxy_pipe.rb @@ -52,7 +52,15 @@ def initialize(io, compress: true) end def self.finalize(io) - proc { io.close } + proc do + io.close + rescue ThreadError + # io.close is forbidden inside a signal trap context (Ruby raises + # ThreadError). SolidQueue and other job backends install persistent + # Signal.trap handlers; GC triggered while a trap is active will + # fire this finalizer in that context. The OS closes the fd on + # process exit, so silently skipping here is safe. + end end attr_reader :io diff --git a/spec/elastic_apm/spies/solid_queue_spec.rb b/spec/elastic_apm/spies/solid_queue_spec.rb new file mode 100644 index 000000000..336c0e0e1 --- /dev/null +++ b/spec/elastic_apm/spies/solid_queue_spec.rb @@ -0,0 +1,131 @@ +# Licensed to Elasticsearch B.V. under one or more contributor +# license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright +# ownership. Elasticsearch B.V. licenses this file to you under +# the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# frozen_string_literal: true + +require 'spec_helper' + +# Solid Queue ships +SolidQueue::ClaimedExecution+ as an ActiveRecord model +# under +app/models+, loaded via the engine through Zeitwerk. To avoid +# requiring a full Rails boot here, the spec defines a stand-in constant with +# the same shape and lets the spy hook it. +module SolidQueue + class ClaimedExecution + attr_reader :job + + def initialize(job) + @job = job + end + + # Real solid_queue runs +ActiveJob::Base.execute+ here. For the spec we + # just dispatch to the test job stub so that +super+ inside the spy's + # +Ext+ module is exercised end-to-end. + def perform + job.run if job.respond_to?(:run) + end + end +end + +require 'elastic_apm/spies/solid_queue' + +module ElasticAPM + RSpec.describe 'Spy: SolidQueue', :intercept do + class TestSolidQueueJob + attr_reader :queue_name + + def initialize(queue_name: 'default') + @queue_name = queue_name + end + + def class_name + self.class.name + end + + def run + end + end + + class ExplodingSolidQueueJob < TestSolidQueueJob + def run + raise ZeroDivisionError, 'boom' + end + end + + it 'instruments successful job perform' do + with_agent do + ::SolidQueue::ClaimedExecution.new(TestSolidQueueJob.new).perform + end + + transaction, = @intercepted.transactions + expect(transaction).to_not be_nil + expect(transaction.name).to eq 'ElasticAPM::TestSolidQueueJob' + expect(transaction.type).to eq 'SolidQueue' + expect(transaction.result).to eq 'success' + expect(transaction.outcome).to eq 'success' + + labels = transaction.context.labels + expect(labels[:queue]).to eq 'default' + end + + it 'reports errors and marks transaction as failure' do + expect do + with_agent do + ::SolidQueue::ClaimedExecution.new(ExplodingSolidQueueJob.new).perform + end + end.to raise_error(ZeroDivisionError) + + transaction, = @intercepted.transactions + expect(transaction.name).to eq 'ElasticAPM::ExplodingSolidQueueJob' + expect(transaction.type).to eq 'SolidQueue' + expect(transaction.outcome).to eq 'failure' + expect(transaction.result).to eq 'error' + + error, = @intercepted.errors + expect(error.exception.type).to eq 'ZeroDivisionError' + end + + it 'captures the queue label from the job' do + with_agent do + job = TestSolidQueueJob.new(queue_name: 'critical') + ::SolidQueue::ClaimedExecution.new(job).perform + end + + transaction, = @intercepted.transactions + expect(transaction.context.labels[:queue]).to eq 'critical' + end + + it 'creates a transaction for each perform call' do + with_agent do + ::SolidQueue::ClaimedExecution.new(TestSolidQueueJob.new).perform + ::SolidQueue::ClaimedExecution.new(TestSolidQueueJob.new).perform + end + + expect(@intercepted.transactions.size).to eq 2 + end + + it 'prepends the Ext module onto SolidQueue::ClaimedExecution' do + expect(::SolidQueue::ClaimedExecution.ancestors) + .to include(Spies::SolidQueueSpy::Ext) + end + + it "runs when the agent doesn't" do + expect do + ::SolidQueue::ClaimedExecution.new(TestSolidQueueJob.new).perform + end.to_not raise_error + end + end +end