Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions docs/reference/supported-technologies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions docs/release-notes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions lib/elastic_apm/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ def available_instrumentations
sinatra
sneakers
sns
solid_queue
sqs
sucker_punch
tilt
Expand Down
80 changes: 80 additions & 0 deletions lib/elastic_apm/spies/solid_queue.rb
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion lib/elastic_apm/transport/connection/proxy_pipe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,15 @@ def initialize(io, compress: true)
end

def self.finalize(io)
proc { io.close }
proc do

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The finalizer registered via ObjectSpace.define_finalizer in proxy_pipe.rb calls io.close, which Ruby forbids inside a signal trap context (ThreadError). SolidQueue workers install persistent Signal.trap handlers; if GC runs while a trap is active (triggered by normal allocations mid-job), the finalizer fires in that context and Ruby
prints a warning. Fixed by rescuing ThreadError in the finalizer the fd is reclaimed by the OS on process exit, so skipping silently is safe

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
Expand Down
131 changes: 131 additions & 0 deletions spec/elastic_apm/spies/solid_queue_spec.rb
Original file line number Diff line number Diff line change
@@ -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