Skip to content

Commit c9da666

Browse files
committed
WIP
1 parent 6a62844 commit c9da666

9 files changed

Lines changed: 215 additions & 55 deletions

File tree

.devcontainer/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,11 @@ SENTRY_E2E_SVELTE_APP_PORT=4001
1212
SENTRY_E2E_RAILS_APP_URL="http://localhost:4000"
1313
SENTRY_E2E_SVELTE_APP_URL="http://localhost:4001"
1414

15+
# ActiveJob queue adapter under test: async | inline | sidekiq | resque | delayed_job
16+
SENTRY_E2E_ACTIVE_JOB_ADAPTER="async"
17+
18+
# Redis for the sidekiq/resque adapters (the Compose service is named "redis")
19+
REDIS_URL="redis://redis:6379"
20+
1521
# Faster builds with compose
1622
COMPOSE_BAKE=true

.devcontainer/docker-compose.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ services:
2525
command: ["mise", "run", "e2e:serve"]
2626
environment:
2727
BUNDLE_PATH: /home/sentry/bundle
28+
depends_on:
29+
redis:
30+
condition: service_healthy
2831
volumes:
2932
- ..:/workspace/sentry:cached
3033
- bundle-gems:/home/sentry/bundle
@@ -38,6 +41,11 @@ services:
3841
- ALLOW_EMPTY_PASSWORD=yes
3942
ports:
4043
- "6379:6379"
44+
healthcheck:
45+
test: ["CMD", "redis-cli", "ping"]
46+
interval: 2s
47+
timeout: 3s
48+
retries: 10
4149

4250
volumes:
4351
bundle-gems:

.github/workflows/e2e_tests.yml

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,24 @@ concurrency:
1717

1818
jobs:
1919
e2e-tests:
20-
name: e2e tests
20+
name: e2e tests (ruby ${{ matrix.ruby.flavor }}, ${{ matrix.adapter }})
2121
runs-on: ubuntu-latest
22-
timeout-minutes: 5
22+
timeout-minutes: 8
2323

2424
strategy:
2525
fail-fast: false
2626
matrix:
27-
include:
28-
- ruby_version: "3.4.9"
27+
ruby:
28+
- version: "3.4.9"
2929
flavor: "3.4"
30-
- ruby_version: "4.0.3"
30+
- version: "4.0.3"
3131
flavor: "4.0"
32+
adapter:
33+
- async
34+
- inline
35+
- sidekiq
36+
- resque
37+
- delayed_job
3238

3339
steps:
3440
- name: Checkout code
@@ -42,9 +48,10 @@ jobs:
4248
run: |
4349
cd .devcontainer
4450
cp .env.example .env
45-
echo "RUBY_VERSION=${{ matrix.ruby_version }}" >> .env
46-
echo "DOCKER_IMAGE=ghcr.io/getsentry/sentry-ruby-devcontainer-${{ matrix.flavor }}" >> .env
51+
echo "RUBY_VERSION=${{ matrix.ruby.version }}" >> .env
52+
echo "DOCKER_IMAGE=ghcr.io/getsentry/sentry-ruby-devcontainer-${{ matrix.ruby.flavor }}" >> .env
4753
echo "DOCKER_TAG=${{ steps.devcontainer-version.outputs.version }}" >> .env
54+
echo "SENTRY_E2E_ACTIVE_JOB_ADAPTER=${{ matrix.adapter }}" >> .env
4855
4956
- name: Log in to GHCR
5057
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
@@ -54,7 +61,7 @@ jobs:
5461
password: ${{ secrets.GITHUB_TOKEN }}
5562

5663
- name: Pull test container image
57-
run: docker pull ghcr.io/getsentry/sentry-ruby-devcontainer-${{ matrix.flavor }}:${{ steps.devcontainer-version.outputs.version }}
64+
run: docker pull ghcr.io/getsentry/sentry-ruby-devcontainer-${{ matrix.ruby.flavor }}:${{ steps.devcontainer-version.outputs.version }}
5865

5966
- name: Restore node_modules cache
6067
uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3
@@ -114,7 +121,7 @@ jobs:
114121
if: failure()
115122
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
116123
with:
117-
name: e2e-test-logs-ruby-${{ matrix.ruby_version }}
124+
name: e2e-test-logs-ruby-${{ matrix.ruby.version }}-${{ matrix.adapter }}
118125
path: |
119126
log/sentry_debug_events.log
120127
retention-days: 7

.mise.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ run = "cd spec/apps/rails-mini && bundle exec ruby app.rb"
1414
description = "Start the svelte-mini e2e app"
1515
run = "cd spec/apps/svelte-mini && npm run dev"
1616

17+
[tasks."e2e:worker"]
18+
description = "Start the rails-mini ActiveJob worker (sidekiq/resque/delayed_job; idles for async/inline)"
19+
run = "cd spec/apps/rails-mini && bundle exec ruby worker.rb"
20+
1721
[tasks."e2e:serve"]
1822
description = "Start all e2e apps in parallel"
19-
depends = ["e2e:rails", "e2e:svelte"]
23+
depends = ["e2e:rails", "e2e:svelte", "e2e:worker"]

spec/apps/rails-mini/Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ gem 'sqlite3'
1313

1414
gem 'sentry-ruby', path: Pathname(__dir__).join("../../..").realpath
1515
gem 'sentry-rails', path: Pathname(__dir__).join("../../..").realpath
16+
gem 'sidekiq'
17+
gem 'resque'
18+
gem 'delayed_job_active_record'

spec/apps/rails-mini/app.rb

Lines changed: 110 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
require "action_controller/railtie"
1111
require "active_record/railtie"
1212
require "active_job/railtie"
13+
require "time"
14+
15+
# Point the broker-backed adapters at Redis. Sidekiq reads REDIS_URL on
16+
# its own; Resque does not, so wire it up explicitly. Defaults to a local
17+
# Redis when REDIS_URL is unset (e.g. running outside Docker Compose).
18+
redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379")
19+
Resque.redis = redis_url if defined?(Resque)
1320

1421
class RailsMiniApp < Rails::Application
1522
config.hosts = nil
@@ -20,6 +27,29 @@ class RailsMiniApp < Rails::Application
2027
config.api_only = true
2128
config.force_ssl = false
2229

30+
# Select the ActiveJob queue adapter from the environment. This must be
31+
# assigned in the application body (not inside an `initializer` block):
32+
# ActiveJob's own `active_job.set_configs` initializer reads
33+
# `config.active_job.queue_adapter` and applies it via an `on_load`
34+
# hook that fires during boot, before app-defined initializers run. An
35+
# assignment made from an initializer would therefore be a silent no-op
36+
# and every adapter would fall back to the default :async.
37+
SUPPORTED_ACTIVE_JOB_ADAPTERS = {
38+
"async" => :async,
39+
"inline" => :inline,
40+
"sidekiq" => :sidekiq,
41+
"resque" => :resque,
42+
"delayed_job" => :delayed_job
43+
}.freeze
44+
45+
adapter_name = ENV.fetch("SENTRY_E2E_ACTIVE_JOB_ADAPTER", "async").to_s.downcase
46+
unless SUPPORTED_ACTIVE_JOB_ADAPTERS.key?(adapter_name)
47+
raise "Unsupported ActiveJob adapter: #{adapter_name}"
48+
end
49+
50+
config.active_job.queue_adapter = SUPPORTED_ACTIVE_JOB_ADAPTERS[adapter_name]
51+
config.x.active_job_adapter_name = adapter_name
52+
2353
def debug_log_path
2454
@log_path ||= begin
2555
path = Pathname(__dir__).join("../../../log")
@@ -211,48 +241,64 @@ def set_cors_headers
211241
class JobsController < ActionController::Base
212242
before_action :set_cors_headers
213243

214-
def sample_job
215-
job = SampleJob.perform_later("Hello from Rails mini app!")
244+
JOB_CLASSES = {
245+
"sample" => SampleJob,
246+
"database" => DatabaseJob,
247+
"failing" => FailingJob
248+
}.freeze
216249

217-
Sentry.logger.info("SampleJob enqueued", job_id: job.job_id)
250+
def enqueue
251+
job_type = params[:job_type] || params[:id] || params[:job] || "sample"
252+
job_class = JOB_CLASSES[job_type.to_s]
253+
raise ActionController::BadRequest.new("Unsupported job type: #{job_type}") unless job_class
218254

219-
render json: {
220-
message: "SampleJob enqueued successfully",
221-
job_id: job.job_id,
222-
job_class: job.class.name
223-
}
224-
end
255+
args = Array(params[:args] || [])
256+
args = JSON.parse(args) if args.is_a?(String) && args.strip.start_with?("[")
225257

226-
def database_job
227-
title = params[:title] || "Test Post from Job"
228-
job = DatabaseJob.perform_later(title)
258+
job = schedule_job(job_class, args)
229259

230-
Sentry.logger.info("DatabaseJob enqueued", job_id: job.job_id, post_title: title)
260+
Sentry.logger.info(
261+
"#{job_class.name} enqueued",
262+
job_id: job.job_id,
263+
job_class: job.class.name,
264+
args: args
265+
)
231266

232-
render json: {
233-
message: "DatabaseJob enqueued successfully",
267+
response_body = {
268+
message: "#{job_class.name} enqueued successfully",
234269
job_id: job.job_id,
235270
job_class: job.class.name,
236-
post_title: title
271+
args: args
237272
}
238-
end
239273

240-
def failing_job
241-
should_fail = params[:should_fail] != "false"
242-
job = FailingJob.perform_later(should_fail)
274+
if job_type.to_s == "database"
275+
response_body[:post_title] = args[0] || "Test Post from Job"
276+
elsif job_type.to_s == "failing"
277+
response_body[:should_fail] = args.empty? ? true : args.first
278+
end
243279

244-
Sentry.logger.info("FailingJob enqueued", job_id: job.job_id, should_fail: should_fail)
280+
render json: response_body
281+
end
245282

283+
def active_job_adapter
246284
render json: {
247-
message: "FailingJob enqueued successfully",
248-
job_id: job.job_id,
249-
job_class: job.class.name,
250-
should_fail: should_fail
285+
adapter: Rails.configuration.x.active_job_adapter_name,
286+
queue_adapter: ActiveJob::Base.queue_adapter.class.name
251287
}
252288
end
253289

254290
private
255291

292+
def schedule_job(job_class, args)
293+
if params[:wait_seconds].present?
294+
job_class.set(wait: params[:wait_seconds].to_i.seconds).perform_later(*args)
295+
elsif params[:wait_until].present?
296+
job_class.set(wait_until: Time.parse(params[:wait_until])).perform_later(*args)
297+
else
298+
job_class.perform_later(*args)
299+
end
300+
end
301+
256302
def set_cors_headers
257303
response.headers['Access-Control-Allow-Origin'] = '*'
258304
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
@@ -262,23 +308,45 @@ def set_cors_headers
262308

263309
RailsMiniApp.initialize!
264310

265-
ActiveRecord::Schema.define do
266-
create_table :posts, force: true do |t|
267-
t.string :title, null: false
268-
t.text :content
269-
t.timestamps
270-
end
311+
# The web process owns schema setup. The worker (worker.rb) boots the same
312+
# app in parallel and sets SENTRY_E2E_SKIP_DB_SETUP=true to skip this block,
313+
# avoiding a concurrent `force: true` drop/create race on the shared SQLite
314+
# file; it waits for these tables to appear before processing jobs.
315+
unless ENV["SENTRY_E2E_SKIP_DB_SETUP"] == "true"
316+
ActiveRecord::Schema.define do
317+
create_table :posts, force: true do |t|
318+
t.string :title, null: false
319+
t.text :content
320+
t.timestamps
321+
end
322+
323+
create_table :users, force: true do |t|
324+
t.string :name, null: false
325+
t.string :email
326+
t.timestamps
327+
end
271328

272-
create_table :users, force: true do |t|
273-
t.string :name, null: false
274-
t.string :email
275-
t.timestamps
329+
# Backing store for the :delayed_job adapter. Created unconditionally so
330+
# the same schema works regardless of which adapter the worker uses.
331+
create_table :delayed_jobs, force: true do |t|
332+
t.integer :priority, default: 0, null: false
333+
t.integer :attempts, default: 0, null: false
334+
t.text :handler, null: false
335+
t.text :last_error
336+
t.datetime :run_at
337+
t.datetime :locked_at
338+
t.datetime :failed_at
339+
t.string :locked_by
340+
t.string :queue
341+
t.timestamps null: true
342+
end
343+
add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority"
276344
end
277-
end
278345

279-
Post.create!(title: "Welcome Post", content: "Welcome to the Rails mini app!")
280-
Post.create!(title: "Sample Post", content: "This is a sample post for testing.")
281-
User.create!(name: "Test User", email: "test@example.com")
346+
Post.create!(title: "Welcome Post", content: "Welcome to the Rails mini app!")
347+
Post.create!(title: "Sample Post", content: "This is a sample post for testing.")
348+
User.create!(name: "Test User", email: "test@example.com")
349+
end
282350

283351
RailsMiniApp.routes.draw do
284352
get '/health', to: 'events#health'
@@ -291,9 +359,9 @@ def set_cors_headers
291359
post '/posts', to: 'posts#create'
292360
get '/posts/:id', to: 'posts#show'
293361

294-
post '/jobs/sample', to: 'jobs#sample_job'
295-
post '/jobs/database', to: 'jobs#database_job'
296-
post '/jobs/failing', to: 'jobs#failing_job'
362+
post '/jobs/enqueue', to: 'jobs#enqueue'
363+
post '/jobs/:job_type', to: 'jobs#enqueue'
364+
get '/jobs/adapter', to: 'jobs#active_job_adapter'
297365

298366
match '*path', to: proc { |env|
299367
[200, {

spec/apps/rails-mini/worker.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
# Background worker entrypoint for the worker-based ActiveJob adapters
4+
# (:sidekiq, :resque, :delayed_job). These adapters enqueue onto an
5+
# external broker (Redis / the DB) and rely on a separate process to
6+
# execute the job. The worker boots the same Rails + Sentry app as the
7+
# web process, so the job's consumer transaction is emitted into the
8+
# shared debug-transport log the e2e suite reads.
9+
#
10+
# :async and :inline run inside the web process and need no worker here.
11+
12+
adapter = ENV.fetch("SENTRY_E2E_ACTIVE_JOB_ADAPTER", "async").to_s.downcase
13+
14+
# The web process owns schema setup; the worker must not recreate the tables
15+
# concurrently. Both processes share the same SQLite file.
16+
ENV["SENTRY_E2E_SKIP_DB_SETUP"] = "true"
17+
18+
# Sidekiq ships its own CLI that boots the app via -r; hand off to it
19+
# directly instead of double-booting Rails in this process. SENTRY_E2E_SKIP_DB_SETUP
20+
# is inherited by the exec'd process, so it skips schema setup too.
21+
if adapter == "sidekiq"
22+
exec("bundle", "exec", "sidekiq", "-r", "./app.rb", "-c", "2", "-q", "default")
23+
end
24+
25+
require_relative "app"
26+
27+
# Wait for the web process to finish creating the schema before consuming
28+
# jobs (the `posts` table is created in the same block as the others).
29+
60.times do
30+
break if ActiveRecord::Base.connection.table_exists?(:posts)
31+
32+
sleep 0.5
33+
end
34+
35+
case adapter
36+
when "resque"
37+
# Process every queue in-process (no fork) so the Sentry SDK state set
38+
# up at boot stays intact while the job runs.
39+
queues = ENV.fetch("QUEUES", "*").split(",")
40+
ENV["FORK_PER_JOB"] ||= "false"
41+
worker = Resque::Worker.new(*queues)
42+
worker.work(ENV.fetch("RESQUE_INTERVAL", "0.5").to_f)
43+
when "delayed_job"
44+
Delayed::Worker.new(sleep_delay: 0.5, quiet: false).start
45+
else
46+
# :async and :inline run jobs inside the web process. Stay alive as an
47+
# idle no-op so this stays a uniform, long-running service under process
48+
# supervisors (mise `e2e:serve`, Docker Compose) regardless of adapter.
49+
warn "No external worker needed for adapter: #{adapter.inspect}; idling."
50+
sleep
51+
end

spec/features/active_job_tracing_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def wait_for_trace(timeout: 10)
6767

6868
transactions.each do |http_txn|
6969
next unless http_txn.dig("contexts", "trace", "op") == "http.server"
70-
next unless http_txn["transaction"] =~ /JobsController#sample_job/
70+
next unless http_txn["transaction"] =~ /JobsController#enqueue/
7171

7272
trace_id = http_txn.dig("contexts", "trace", "trace_id")
7373
job_txn = transactions.find do |t|

0 commit comments

Comments
 (0)