From b40da76fc6a7846f1c7f9cd9117eab21ac45e31a Mon Sep 17 00:00:00 2001
From: Noemi Lapresta
Date: Thu, 9 Jul 2026 13:43:53 +0200
Subject: [PATCH 01/12] Cross-service trace propagation in rails7-sidekiq
Collector mode adds W3C trace-context propagation, but no setup showed
a trace spanning two AppSignal apps: the HTTP client actions called
localhost (the app calling itself), and background jobs ran under a
single app name.
Make rails7-sidekiq collector-capable and run a second instance of the
same image as a distinct "downstream" app. The HTTP client actions now
call it instead of themselves, and a new DownstreamWorker runs on a
queue only the downstream service drains, alongside the existing
same-service worker. In collector mode the trace continues across both
apps over HTTP, and the job's trace links back to the enqueue span.
Exercises appsignal-ruby#1535.
---
README.md | 1 +
ruby/rails7-sidekiq/app/Gemfile | 10 +++
ruby/rails7-sidekiq/app/Gemfile.lock | 61 +++++++++++++++++
.../app/controllers/requests_controller.rb | 34 ++++++----
.../app/app/controllers/workers_controller.rb | 8 +++
.../app/app/views/workers/index.html.erb | 10 +++
.../app/app/workers/downstream_worker.rb | 14 ++++
.../app/config/environments/development.rb | 5 ++
ruby/rails7-sidekiq/app/config/routes.rb | 1 +
.../app/processmon.downstream.toml | 34 ++++++++++
ruby/rails7-sidekiq/commands/run-downstream | 18 +++++
ruby/rails7-sidekiq/docker-compose.agent.yml | 10 +++
.../docker-compose.collector.yml | 13 ++++
ruby/rails7-sidekiq/docker-compose.shared.yml | 65 +++++++++++++++++++
ruby/rails7-sidekiq/docker-compose.yml | 41 ------------
15 files changed, 272 insertions(+), 53 deletions(-)
create mode 100644 ruby/rails7-sidekiq/app/app/workers/downstream_worker.rb
create mode 100644 ruby/rails7-sidekiq/app/processmon.downstream.toml
create mode 100755 ruby/rails7-sidekiq/commands/run-downstream
create mode 100644 ruby/rails7-sidekiq/docker-compose.agent.yml
create mode 100644 ruby/rails7-sidekiq/docker-compose.collector.yml
create mode 100644 ruby/rails7-sidekiq/docker-compose.shared.yml
delete mode 100644 ruby/rails7-sidekiq/docker-compose.yml
diff --git a/README.md b/README.md
index 07fff883f..291c80b56 100644
--- a/README.md
+++ b/README.md
@@ -265,6 +265,7 @@ rake app=ruby/rails7-delayed-job mode=collector app:up
rake app=ruby/rails7-goodjob mode=collector app:up
rake app=ruby/rails7-postgres mode=collector app:up
rake app=ruby/rails7-sequel mode=collector app:up
+rake app=ruby/rails7-sidekiq mode=collector app:up
rake app=ruby/rails7-solid-cache mode=collector app:up
rake app=ruby/rails7-solid-queue mode=collector app:up
rake app=ruby/rails8-delayed-job mode=collector app:up
diff --git a/ruby/rails7-sidekiq/app/Gemfile b/ruby/rails7-sidekiq/app/Gemfile
index b01d778ad..e4b48f96f 100644
--- a/ruby/rails7-sidekiq/app/Gemfile
+++ b/ruby/rails7-sidekiq/app/Gemfile
@@ -14,6 +14,16 @@ gem "excon"
gem "http"
gem "faraday"
+# OpenTelemetry gems for AppSignal collector mode. Loaded only when
+# APPSIGNAL_COLLECTOR_ENDPOINT is set; keep versions in sync with appsignal's
+# lib/appsignal/opentelemetry/dependencies.rb.
+gem "opentelemetry-sdk", ">= 1.8.0"
+gem "opentelemetry-metrics-sdk", ">= 0.7.1"
+gem "opentelemetry-logs-sdk", ">= 0.2.0"
+gem "opentelemetry-exporter-otlp", ">= 0.30.0"
+gem "opentelemetry-exporter-otlp-metrics", ">= 0.4.0"
+gem "opentelemetry-exporter-otlp-logs", ">= 0.2.0"
+
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
gem "sprockets-rails"
diff --git a/ruby/rails7-sidekiq/app/Gemfile.lock b/ruby/rails7-sidekiq/app/Gemfile.lock
index 2dee8db21..8b9485392 100644
--- a/ruby/rails7-sidekiq/app/Gemfile.lock
+++ b/ruby/rails7-sidekiq/app/Gemfile.lock
@@ -122,6 +122,11 @@ GEM
rake
globalid (1.2.1)
activesupport (>= 6.1)
+ google-protobuf (4.33.6)
+ bigdecimal
+ rake (>= 13)
+ googleapis-common-protos-types (1.22.0)
+ google-protobuf (~> 4.26)
http (5.3.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
@@ -178,6 +183,56 @@ GEM
nio4r (2.7.3)
nokogiri (1.16.5-aarch64-linux)
racc (~> 1.4)
+ opentelemetry-api (1.8.0)
+ logger
+ opentelemetry-common (0.23.0)
+ opentelemetry-api (~> 1.0)
+ opentelemetry-exporter-otlp (0.32.0)
+ google-protobuf (>= 3.18)
+ googleapis-common-protos-types (~> 1.3)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-sdk (~> 1.10)
+ opentelemetry-semantic_conventions
+ opentelemetry-exporter-otlp-logs (0.3.0)
+ google-protobuf (>= 3.18)
+ googleapis-common-protos-types (~> 1.3)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-logs-api (~> 0.1)
+ opentelemetry-logs-sdk (~> 0.1)
+ opentelemetry-sdk
+ opentelemetry-semantic_conventions
+ opentelemetry-exporter-otlp-metrics (0.7.0)
+ google-protobuf (>= 3.18, < 5.0)
+ googleapis-common-protos-types (~> 1.3)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-metrics-api (~> 0.2)
+ opentelemetry-metrics-sdk (~> 0.5)
+ opentelemetry-sdk (~> 1.2)
+ opentelemetry-semantic_conventions
+ opentelemetry-logs-api (0.2.0)
+ opentelemetry-api (~> 1.0)
+ opentelemetry-logs-sdk (0.4.0)
+ opentelemetry-api (~> 1.2)
+ opentelemetry-logs-api (~> 0.1)
+ opentelemetry-sdk (~> 1.3)
+ opentelemetry-metrics-api (0.4.0)
+ opentelemetry-api (~> 1.0)
+ opentelemetry-metrics-sdk (0.12.0)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-metrics-api (~> 0.2)
+ opentelemetry-sdk (~> 1.2)
+ opentelemetry-registry (0.4.0)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-sdk (1.10.0)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-registry (~> 0.2)
+ opentelemetry-semantic_conventions
+ opentelemetry-semantic_conventions (1.36.0)
+ opentelemetry-api (~> 1.0)
pg (1.5.4)
psych (5.1.2)
stringio
@@ -309,6 +364,12 @@ DEPENDENCIES
http
importmap-rails
jbuilder
+ opentelemetry-exporter-otlp (>= 0.30.0)
+ opentelemetry-exporter-otlp-logs (>= 0.2.0)
+ opentelemetry-exporter-otlp-metrics (>= 0.4.0)
+ opentelemetry-logs-sdk (>= 0.2.0)
+ opentelemetry-metrics-sdk (>= 0.7.1)
+ opentelemetry-sdk (>= 1.8.0)
pg (~> 1.1)
puma (~> 6.0)
rails (~> 7.1.0)
diff --git a/ruby/rails7-sidekiq/app/app/controllers/requests_controller.rb b/ruby/rails7-sidekiq/app/app/controllers/requests_controller.rb
index e4af0eb3f..0cd75c41c 100644
--- a/ruby/rails7-sidekiq/app/app/controllers/requests_controller.rb
+++ b/ruby/rails7-sidekiq/app/app/controllers/requests_controller.rb
@@ -1,13 +1,21 @@
class RequestsController < ApplicationController
skip_forgery_protection
+ # The HTTP client actions call a second, separately-instrumented app (the
+ # `downstream` service) so the injected `traceparent` is extracted there and
+ # the trace spans both apps. Falls back to calling this app itself when
+ # DOWNSTREAM_URL is unset, so the setup still works as a single service.
+ def self.downstream_url(path)
+ "#{ENV.fetch("DOWNSTREAM_URL", "http://localhost:4001")}#{path}"
+ end
+
def perform_excon_get
- handle_response Excon.get("http://localhost:4001/requests/excon_get?query=param")
+ handle_response Excon.get(self.class.downstream_url("/requests/excon_get?query=param"))
end
def perform_excon_post
handle_response Excon.post(
- "http://localhost:4001/requests/excon_post",
+ self.class.downstream_url("/requests/excon_post"),
:body => URI.encode_www_form(:language => "ruby", :class => "fog"),
:headers => { "Content-Type" => "application/x-www-form-urlencoded" }
)
@@ -15,7 +23,7 @@ def perform_excon_post
def perform_excon_put
handle_response Excon.put(
- "http://localhost:4001/requests/excon_put",
+ self.class.downstream_url("/requests/excon_put"),
:body => URI.encode_www_form(:language => "ruby", :class => "fog"),
:headers => { "Content-Type" => "application/x-www-form-urlencoded" }
)
@@ -23,26 +31,28 @@ def perform_excon_put
def perform_excon_delete
handle_response Excon.delete(
- "http://localhost:4001/requests/excon_delete",
+ self.class.downstream_url("/requests/excon_delete"),
:body => URI.encode_www_form(:language => "ruby", :class => "fog"),
:headers => { "Content-Type" => "application/x-www-form-urlencoded" }
)
end
def perform_excon_head
- handle_response Excon.head("http://localhost:4001/requests/excon_get")
+ handle_response Excon.head(self.class.downstream_url("/requests/excon_get"))
end
def perform_excon_options
- handle_response Excon.options("http://localhost:4001/requests/excon_options")
+ handle_response Excon.options(self.class.downstream_url("/requests/excon_options"))
end
def perform_excon_trace
- handle_response Excon.trace("http://localhost:4001/requests/excon_trace")
+ handle_response Excon.trace(self.class.downstream_url("/requests/excon_trace"))
end
+ # Hits the downstream app so the request span continues into it. (Previously
+ # this called example.com, which is uninstrumented, so nothing continued.)
def perform_http_rb_get
- HTTP.get("https://example.com")
+ HTTP.get(self.class.downstream_url("/requests/http_rb_target"))
redirect_to requests_path, :notice => %(Request "#{params[:action]}" made)
end
@@ -51,14 +61,14 @@ def perform_http_rb_get
# These actions exercise that path so it records a `request.http_rb` event.
def perform_http_rb_headers
HTTP.headers("X-Custom-Header" => "AppSignal")
- .get("http://localhost:4001/requests/http_rb_target")
+ .get(self.class.downstream_url("/requests/http_rb_target"))
redirect_to requests_path, :notice => %(Request "#{params[:action]}" made)
end
# A followed redirect should stay a single `request.http_rb` event spanning
# every hop, not one event per hop.
def perform_http_rb_follow
- HTTP.follow.get("http://localhost:4001/requests/http_rb_redirect")
+ HTTP.follow.get(self.class.downstream_url("/requests/http_rb_redirect"))
redirect_to requests_path, :notice => %(Request "#{params[:action]}" made)
end
@@ -75,12 +85,12 @@ def http_rb_redirect
# suppressing the downstream Net::HTTP event: the request should be recorded
# once, as a `request.faraday` event.
def perform_faraday_get
- handle_response Faraday.get("http://localhost:4001/requests/faraday_get?query=param")
+ handle_response Faraday.get(self.class.downstream_url("/requests/faraday_get?query=param"))
end
def perform_faraday_post
handle_response Faraday.post(
- "http://localhost:4001/requests/faraday_post",
+ self.class.downstream_url("/requests/faraday_post"),
URI.encode_www_form(:language => "ruby", :library => "faraday"),
"Content-Type" => "application/x-www-form-urlencoded"
)
diff --git a/ruby/rails7-sidekiq/app/app/controllers/workers_controller.rb b/ruby/rails7-sidekiq/app/app/controllers/workers_controller.rb
index 478b5a8f0..9c96e812d 100644
--- a/ruby/rails7-sidekiq/app/app/controllers/workers_controller.rb
+++ b/ruby/rails7-sidekiq/app/app/controllers/workers_controller.rb
@@ -54,6 +54,14 @@ def queue
redirect_to({ :action => :index }, :notice => "Worker queued")
end
+ # Enqueue a job onto the `downstream` queue, which only the separately
+ # instrumented `downstream` service drains. In collector mode this shows job
+ # trace propagation across two services.
+ def cross_service
+ DownstreamWorker.perform_async("Cross-service test")
+ redirect_to({ :action => :index }, :notice => "Cross-service worker queued")
+ end
+
def handle_sidekiq(worker, args:, test:, future:)
if future
worker.perform_in(DELAY_DURATION.from_now, *args)
diff --git a/ruby/rails7-sidekiq/app/app/views/workers/index.html.erb b/ruby/rails7-sidekiq/app/app/views/workers/index.html.erb
index 8297b93fb..31b862ccf 100644
--- a/ruby/rails7-sidekiq/app/app/views/workers/index.html.erb
+++ b/ruby/rails7-sidekiq/app/app/views/workers/index.html.erb
@@ -21,6 +21,16 @@
<% end %>
+Queue a cross-service Sidekiq worker
+
+Enqueues a job onto the downstream queue, performed by the
+separately-instrumented downstream service. In collector mode the job's trace
+links back to the enqueue span across both apps.
+
+
+ - <%= link_to "DownstreamWorker (cross-service)", cross_service_workers_path %>
+
+
Sidekiq monitoring page
diff --git a/ruby/rails7-sidekiq/app/app/workers/downstream_worker.rb b/ruby/rails7-sidekiq/app/app/workers/downstream_worker.rb
new file mode 100644
index 000000000..f1f737627
--- /dev/null
+++ b/ruby/rails7-sidekiq/app/app/workers/downstream_worker.rb
@@ -0,0 +1,14 @@
+# Enqueued by the `app` service but performed by the `downstream` service, which
+# is the only one draining the `downstream` queue. In collector mode the trace
+# context rides along on the job payload, so the job's trace (under the
+# downstream app) links back to the enqueue span (under this app) -- job trace
+# propagation across two services.
+class DownstreamWorker
+ include Sidekiq::Worker
+ sidekiq_options :queue => "downstream"
+
+ def perform(argument = nil)
+ sleep 1
+ puts "downstream performed #{argument}!"
+ end
+end
diff --git a/ruby/rails7-sidekiq/app/config/environments/development.rb b/ruby/rails7-sidekiq/app/config/environments/development.rb
index 2e7fb486b..23e690b41 100644
--- a/ruby/rails7-sidekiq/app/config/environments/development.rb
+++ b/ruby/rails7-sidekiq/app/config/environments/development.rb
@@ -73,4 +73,9 @@
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true
+
+ # Permit the `downstream` service hostname so the HTTP client actions can reach
+ # the second app by its compose service name (Rails host authorization blocks
+ # unknown hosts otherwise).
+ config.hosts << "downstream"
end
diff --git a/ruby/rails7-sidekiq/app/config/routes.rb b/ruby/rails7-sidekiq/app/config/routes.rb
index f180f697e..a73a7ef53 100644
--- a/ruby/rails7-sidekiq/app/config/routes.rb
+++ b/ruby/rails7-sidekiq/app/config/routes.rb
@@ -19,6 +19,7 @@ class SinatraApp < Sinatra::Base
resources :workers do
collection do
get :queue
+ get :cross_service
end
end
mount Sidekiq::Web => "/sidekiq"
diff --git a/ruby/rails7-sidekiq/app/processmon.downstream.toml b/ruby/rails7-sidekiq/app/processmon.downstream.toml
new file mode 100644
index 000000000..96516951a
--- /dev/null
+++ b/ruby/rails7-sidekiq/app/processmon.downstream.toml
@@ -0,0 +1,34 @@
+[[paths_to_watch]]
+path = "/app"
+ignore = [
+ "tmp",
+ "log",
+ "node_modules",
+ "public/packs"
+]
+
+[[paths_to_watch]]
+path = "/integration"
+
+# Only the downstream service drains this queue, so jobs enqueued by `app` are
+# performed here, under the downstream app name.
+[processes.sidekiq]
+command = "bundle"
+args = [
+ "exec",
+ "sidekiq",
+ "--queue=downstream"
+]
+working_dir = "/app"
+
+# A distinct pidfile so this Rails server doesn't collide with the `app`
+# service's server, which shares the same mounted /app directory.
+[processes.rails]
+command = "bin/rails"
+args = [
+ "server",
+ "--binding=0.0.0.0",
+ "--port=4002",
+ "--pid=/app/tmp/pids/downstream.pid"
+]
+working_dir = "/app"
diff --git a/ruby/rails7-sidekiq/commands/run-downstream b/ruby/rails7-sidekiq/commands/run-downstream
new file mode 100755
index 000000000..2a08e5af5
--- /dev/null
+++ b/ruby/rails7-sidekiq/commands/run-downstream
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /app and /integration volumes. Wait for that to finish (bundle check is
+# read-only, so it's safe to poll) instead of installing concurrently, which
+# would race on the shared vendor/bundle directory.
+echo "Waiting for the app service to install gems into the shared volume..."
+until bundle check >/dev/null 2>&1; do
+ echo "Gems not ready yet, retrying..."
+ sleep 2
+done
+
+echo "Starting downstream processes"
+/commands/processmon processmon.downstream.toml
diff --git a/ruby/rails7-sidekiq/docker-compose.agent.yml b/ruby/rails7-sidekiq/docker-compose.agent.yml
new file mode 100644
index 000000000..6738c3ec3
--- /dev/null
+++ b/ruby/rails7-sidekiq/docker-compose.agent.yml
@@ -0,0 +1,10 @@
+include:
+ - path: docker-compose.shared.yml
+
+services:
+ app:
+ environment:
+ - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq
+ downstream:
+ environment:
+ - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-downstream
diff --git a/ruby/rails7-sidekiq/docker-compose.collector.yml b/ruby/rails7-sidekiq/docker-compose.collector.yml
new file mode 100644
index 000000000..06ca58f13
--- /dev/null
+++ b/ruby/rails7-sidekiq/docker-compose.collector.yml
@@ -0,0 +1,13 @@
+include:
+ - path: docker-compose.shared.yml
+ - path: ../../support/collector/docker-compose.yml
+
+services:
+ app:
+ environment:
+ - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-collector
+ - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099
+ downstream:
+ environment:
+ - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-downstream-collector
+ - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099
diff --git a/ruby/rails7-sidekiq/docker-compose.shared.yml b/ruby/rails7-sidekiq/docker-compose.shared.yml
new file mode 100644
index 000000000..e9ede61cb
--- /dev/null
+++ b/ruby/rails7-sidekiq/docker-compose.shared.yml
@@ -0,0 +1,65 @@
+version: '3.8'
+
+include:
+ - path: ../../support/bot/docker-compose.yml
+
+services:
+ postgres:
+ image: postgres
+ env_file:
+ - postgres.env
+ redis:
+ image: "redis:6.2.7"
+ app:
+ build: .
+ image: ruby/rails7-sidekiq
+ depends_on:
+ - postgres
+ - redis
+ ports:
+ - "4001:4001"
+ environment:
+ - PORT=4001
+ # The HTTP client actions call this second, separately-instrumented app so
+ # the trace propagates across services. Falls back to calling itself when
+ # unset (single-service runs).
+ - DOWNSTREAM_URL=http://downstream:4002
+ env_file:
+ - postgres.env
+ - redis.env
+ - ../../appsignal.env
+ - ../../appsignal_key.env
+ volumes:
+ - ./app:/app
+ - ../integration:/integration
+ # A second instance of the same Rails image, running as a distinct AppSignal
+ # app. It receives the HTTP requests the `app` service makes (trace context
+ # extraction) and drains the `downstream` Sidekiq queue that `app` enqueues to
+ # (job trace propagation). Not published externally; `app` reaches it by name.
+ downstream:
+ build: .
+ image: ruby/rails7-sidekiq
+ command: /commands/run-downstream
+ depends_on:
+ - postgres
+ - redis
+ environment:
+ - PORT=4002
+ env_file:
+ - postgres.env
+ - redis.env
+ - ../../appsignal.env
+ - ../../appsignal_key.env
+ volumes:
+ - ./app:/app
+ - ../integration:/integration
+ tests:
+ build: ../../support/server-tests
+ image: server-tests
+ profiles:
+ - tests
+ depends_on:
+ - app
+ environment:
+ - APP_NAME=ruby/rails7-sidekiq
+ - APP_URL=http://app:4001
diff --git a/ruby/rails7-sidekiq/docker-compose.yml b/ruby/rails7-sidekiq/docker-compose.yml
deleted file mode 100644
index d1999f2d3..000000000
--- a/ruby/rails7-sidekiq/docker-compose.yml
+++ /dev/null
@@ -1,41 +0,0 @@
-version: '3.8'
-
-include:
- - path: ../../support/bot/docker-compose.yml
-
-services:
- postgres:
- image: postgres
- env_file:
- - postgres.env
- redis:
- image: "redis:6.2.7"
- app:
- build: .
- image: ruby/rails7-sidekiq
- depends_on:
- - postgres
- - redis
- ports:
- - "4001:4001"
- environment:
- - APPSIGNAL_APP_NAME=ruby-rails7-sidekiq
- - PORT=4001
- env_file:
- - postgres.env
- - redis.env
- - ../../appsignal.env
- - ../../appsignal_key.env
- volumes:
- - ./app:/app
- - ../integration:/integration
- tests:
- build: ../../support/server-tests
- image: server-tests
- profiles:
- - tests
- depends_on:
- - app
- environment:
- - APP_NAME=ruby/rails7-sidekiq
- - APP_URL=http://app:4001
From 9e4106a06c45627439eb2e2ad38e59239222d8f6 Mon Sep 17 00:00:00 2001
From: Noemi Lapresta
Date: Thu, 9 Jul 2026 14:20:15 +0200
Subject: [PATCH 02/12] Cross-service Resque propagation in rails8-resque
Add a second instance of the image as a distinct "downstream" app that
drains the `downstream` Resque queue this app enqueues to. In collector
mode the job's trace links back to the enqueue span across both apps.
The existing same-service worker (default queue) is unchanged.
Also stop instrumenting the Resque worker as a rake task
(enable_rake_performance_instrumentation = false). Running `rake
resque:work` with that on opens a long-lived "rake" transaction that
every job reuses, so jobs were recorded under the `rake` namespace
instead of `background_job` -- in agent mode too, since rake
instrumentation was added. That also blocks the collector-mode
enqueue->perform link (the perform span is typed Server, not Consumer).
Exercises appsignal-ruby#1535.
---
.../app/app/controllers/workers_controller.rb | 8 +++++++
.../app/app/jobs/downstream_job.rb | 13 +++++++++++
.../app/app/views/workers/index.html.erb | 10 ++++++++
ruby/rails8-resque/app/config/appsignal.rb | 2 +-
ruby/rails8-resque/app/config/routes.rb | 1 +
ruby/rails8-resque/app/lib/tasks/resque.rake | 6 +++--
.../app/processmon.downstream.toml | 23 +++++++++++++++++++
ruby/rails8-resque/commands/run-downstream | 21 +++++++++++++++++
ruby/rails8-resque/docker-compose.agent.yml | 3 +++
.../docker-compose.collector.yml | 4 ++++
ruby/rails8-resque/docker-compose.shared.yml | 16 +++++++++++++
11 files changed, 104 insertions(+), 3 deletions(-)
create mode 100644 ruby/rails8-resque/app/app/jobs/downstream_job.rb
create mode 100644 ruby/rails8-resque/app/processmon.downstream.toml
create mode 100755 ruby/rails8-resque/commands/run-downstream
diff --git a/ruby/rails8-resque/app/app/controllers/workers_controller.rb b/ruby/rails8-resque/app/app/controllers/workers_controller.rb
index cc2fecb59..1bec69da4 100644
--- a/ruby/rails8-resque/app/app/controllers/workers_controller.rb
+++ b/ruby/rails8-resque/app/app/controllers/workers_controller.rb
@@ -32,6 +32,14 @@ def queue
redirect_to({ :action => :index }, :notice => "Worker queued")
end
+ # Enqueue a native Resque job onto the `downstream` queue, which only the
+ # separately instrumented downstream service drains. In collector mode this
+ # shows job trace propagation across two services.
+ def cross_service
+ Resque.enqueue(DownstreamJob, "Cross-service test")
+ redirect_to({ :action => :index }, :notice => "Cross-service job queued")
+ end
+
private
def handle_resque(worker, args)
diff --git a/ruby/rails8-resque/app/app/jobs/downstream_job.rb b/ruby/rails8-resque/app/app/jobs/downstream_job.rb
new file mode 100644
index 000000000..df3676342
--- /dev/null
+++ b/ruby/rails8-resque/app/app/jobs/downstream_job.rb
@@ -0,0 +1,13 @@
+# Enqueued by this app but performed by the separately-instrumented downstream
+# service, which is the only worker draining the `downstream` queue. In collector
+# mode the trace context rides along on the Resque payload, so the job's trace
+# (under the downstream app) links back to the enqueue span (under this app) --
+# job trace propagation across two services.
+class DownstreamJob
+ @queue = :downstream
+
+ def self.perform(argument = nil)
+ sleep 1
+ puts "downstream performed #{argument}!"
+ end
+end
diff --git a/ruby/rails8-resque/app/app/views/workers/index.html.erb b/ruby/rails8-resque/app/app/views/workers/index.html.erb
index b252c1b9c..b5bd5e985 100644
--- a/ruby/rails8-resque/app/app/views/workers/index.html.erb
+++ b/ruby/rails8-resque/app/app/views/workers/index.html.erb
@@ -10,3 +10,13 @@ Job jobs (and mailers) record an enqueue.active_job event.
<%= link_to name, queue_workers_path(:worker => worker, :test => test) %>
<% end %>
+
+Queue a cross-service job
+
+Enqueues a native Resque job onto the downstream queue, performed
+by the separately-instrumented downstream service. In collector mode the job's
+trace links back to the enqueue span across both apps.
+
+
+ - <%= link_to "DownstreamJob (cross-service)", cross_service_workers_path %>
+
diff --git a/ruby/rails8-resque/app/config/appsignal.rb b/ruby/rails8-resque/app/config/appsignal.rb
index 321ba6f3b..b48b43a5d 100644
--- a/ruby/rails8-resque/app/config/appsignal.rb
+++ b/ruby/rails8-resque/app/config/appsignal.rb
@@ -1,5 +1,5 @@
Appsignal.configure do |config|
- config.enable_rake_performance_instrumentation = true
+ config.enable_rake_performance_instrumentation = false
config.activate_if_environment(:development, :production)
end
diff --git a/ruby/rails8-resque/app/config/routes.rb b/ruby/rails8-resque/app/config/routes.rb
index f33e417ee..c2521424c 100644
--- a/ruby/rails8-resque/app/config/routes.rb
+++ b/ruby/rails8-resque/app/config/routes.rb
@@ -3,6 +3,7 @@
resources :workers do
collection do
get :queue
+ get :cross_service
end
end
diff --git a/ruby/rails8-resque/app/lib/tasks/resque.rake b/ruby/rails8-resque/app/lib/tasks/resque.rake
index a80250cc3..851547f5d 100644
--- a/ruby/rails8-resque/app/lib/tasks/resque.rake
+++ b/ruby/rails8-resque/app/lib/tasks/resque.rake
@@ -3,8 +3,10 @@ require "resque/tasks"
namespace :resque do
# `resque:work` runs this first. Loading the Rails environment makes the job
# classes available and runs the initializer that points Resque at Redis.
- # Default to working every queue so `rake resque:work` needs no arguments.
+ # Default to the `default` queue (not `*`) so this app's worker does not drain
+ # the `downstream` queue, which the separately-instrumented downstream service
+ # owns (it exports QUEUE=downstream to override this default).
task :setup => :environment do
- ENV["QUEUE"] ||= "*"
+ ENV["QUEUE"] ||= "default"
end
end
diff --git a/ruby/rails8-resque/app/processmon.downstream.toml b/ruby/rails8-resque/app/processmon.downstream.toml
new file mode 100644
index 000000000..23e246817
--- /dev/null
+++ b/ruby/rails8-resque/app/processmon.downstream.toml
@@ -0,0 +1,23 @@
+[[paths_to_watch]]
+path = "/app"
+ignore = [
+ "tmp",
+ "log",
+ "node_modules",
+ "public/packs"
+]
+
+[[paths_to_watch]]
+path = "/integration"
+
+# Only the downstream service runs this worker, draining the `downstream` queue
+# (set via QUEUE in run-downstream), so jobs enqueued by `app` are performed here
+# under the downstream app name. No web server -- this service only runs jobs.
+[processes.resque]
+command = "bundle"
+args = [
+ "exec",
+ "rake",
+ "resque:work"
+]
+working_dir = "/app"
diff --git a/ruby/rails8-resque/commands/run-downstream b/ruby/rails8-resque/commands/run-downstream
new file mode 100755
index 000000000..8b158a06c
--- /dev/null
+++ b/ruby/rails8-resque/commands/run-downstream
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /app and /integration volumes. Wait for that instead of installing
+# concurrently, which would race on the shared vendor/bundle directory.
+echo "Waiting for the app service to install gems into the shared volume..."
+until bundle check >/dev/null 2>&1; do
+ echo "Gems not ready yet, retrying..."
+ sleep 2
+done
+
+# Drain only the `downstream` queue (overrides the resque:setup default), so
+# this worker performs the jobs `app` enqueues there, under the downstream app.
+export QUEUE=downstream
+
+echo "Starting downstream Resque worker"
+/commands/processmon processmon.downstream.toml
diff --git a/ruby/rails8-resque/docker-compose.agent.yml b/ruby/rails8-resque/docker-compose.agent.yml
index f5a2acd78..50c3c7760 100644
--- a/ruby/rails8-resque/docker-compose.agent.yml
+++ b/ruby/rails8-resque/docker-compose.agent.yml
@@ -5,3 +5,6 @@ services:
app:
environment:
- APPSIGNAL_APP_NAME=ruby/rails8-resque
+ downstream:
+ environment:
+ - APPSIGNAL_APP_NAME=ruby/rails8-resque-downstream
diff --git a/ruby/rails8-resque/docker-compose.collector.yml b/ruby/rails8-resque/docker-compose.collector.yml
index df42569a5..bb3a7f7dc 100644
--- a/ruby/rails8-resque/docker-compose.collector.yml
+++ b/ruby/rails8-resque/docker-compose.collector.yml
@@ -7,3 +7,7 @@ services:
environment:
- APPSIGNAL_APP_NAME=ruby/rails8-resque-collector
- APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099
+ downstream:
+ environment:
+ - APPSIGNAL_APP_NAME=ruby/rails8-resque-downstream-collector
+ - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099
diff --git a/ruby/rails8-resque/docker-compose.shared.yml b/ruby/rails8-resque/docker-compose.shared.yml
index 42460eb90..da5a9d59b 100644
--- a/ruby/rails8-resque/docker-compose.shared.yml
+++ b/ruby/rails8-resque/docker-compose.shared.yml
@@ -22,6 +22,22 @@ services:
volumes:
- ./app:/app
- ../integration:/integration
+ # A second instance of the same image, running as a distinct AppSignal app. It
+ # drains the `downstream` Resque queue that `app` enqueues to, so the job's
+ # trace links back to the enqueuing app (job trace propagation across services).
+ downstream:
+ build: .
+ image: ruby/rails8-resque
+ command: /commands/run-downstream
+ depends_on:
+ - redis
+ env_file:
+ - redis.env
+ - ../../appsignal.env
+ - ../../appsignal_key.env
+ volumes:
+ - ./app:/app
+ - ../integration:/integration
tests:
build: ../../support/server-tests
image: server-tests
From 74c86818f4255948e501f5d195abbe96b06cf598 Mon Sep 17 00:00:00 2001
From: Noemi Lapresta
Date: Thu, 9 Jul 2026 14:24:47 +0200
Subject: [PATCH 03/12] Cross-service Que propagation in rails8-que
Add a second instance of the image as a distinct "downstream" app that
drains the `downstream` Que queue this app enqueues to (via --queue-name).
In collector mode the job's trace links back to the enqueue span across
both apps. The existing same-service worker (default queue) is unchanged.
Exercises appsignal-ruby#1535.
---
.../app/app/controllers/workers_controller.rb | 8 ++++++
.../rails8-que/app/app/jobs/downstream_job.rb | 11 ++++++++
.../app/app/views/workers/index.html.erb | 10 +++++++
ruby/rails8-que/app/config/routes.rb | 1 +
.../rails8-que/app/processmon.downstream.toml | 26 +++++++++++++++++++
ruby/rails8-que/commands/run-downstream | 17 ++++++++++++
ruby/rails8-que/docker-compose.agent.yml | 3 +++
ruby/rails8-que/docker-compose.collector.yml | 4 +++
ruby/rails8-que/docker-compose.shared.yml | 16 ++++++++++++
9 files changed, 96 insertions(+)
create mode 100644 ruby/rails8-que/app/app/jobs/downstream_job.rb
create mode 100644 ruby/rails8-que/app/processmon.downstream.toml
create mode 100755 ruby/rails8-que/commands/run-downstream
diff --git a/ruby/rails8-que/app/app/controllers/workers_controller.rb b/ruby/rails8-que/app/app/controllers/workers_controller.rb
index 6cb43926a..42e492e27 100644
--- a/ruby/rails8-que/app/app/controllers/workers_controller.rb
+++ b/ruby/rails8-que/app/app/controllers/workers_controller.rb
@@ -38,6 +38,14 @@ def queue
redirect_to({ :action => :index }, :notice => "Worker queued")
end
+ # Enqueue a native Que job onto the `downstream` queue, which only the
+ # separately instrumented downstream service drains. In collector mode this
+ # shows job trace propagation across two services.
+ def cross_service
+ DownstreamJob.enqueue("Cross-service test", :job_options => { :queue => "downstream" })
+ redirect_to({ :action => :index }, :notice => "Cross-service job queued")
+ end
+
private
def handle_que(worker, args)
diff --git a/ruby/rails8-que/app/app/jobs/downstream_job.rb b/ruby/rails8-que/app/app/jobs/downstream_job.rb
new file mode 100644
index 000000000..c85f44033
--- /dev/null
+++ b/ruby/rails8-que/app/app/jobs/downstream_job.rb
@@ -0,0 +1,11 @@
+# Enqueued by this app onto the `downstream` queue but performed by the
+# separately-instrumented downstream service, which is the only worker draining
+# that queue. In collector mode the trace context rides along on the Que job
+# (via a Que tag), so the job's trace (under the downstream app) links back to
+# the enqueue span (under this app) -- job trace propagation across two services.
+class DownstreamJob < Que::Job
+ def run(argument = nil)
+ sleep 1
+ puts "downstream performed #{argument}!"
+ end
+end
diff --git a/ruby/rails8-que/app/app/views/workers/index.html.erb b/ruby/rails8-que/app/app/views/workers/index.html.erb
index ae427ade1..3550e1b4e 100644
--- a/ruby/rails8-que/app/app/views/workers/index.html.erb
+++ b/ruby/rails8-que/app/app/views/workers/index.html.erb
@@ -11,3 +11,13 @@ mailers) record an enqueue.active_job event.
<%= link_to label, queue_workers_path(:type => type, :worker => worker, :test => test) %>
<% end %>
+
+Queue a cross-service job
+
+Enqueues a native Que job onto the downstream queue, performed by
+the separately-instrumented downstream service. In collector mode the job's
+trace links back to the enqueue span across both apps.
+
+
+ - <%= link_to "DownstreamJob (cross-service)", cross_service_workers_path %>
+
diff --git a/ruby/rails8-que/app/config/routes.rb b/ruby/rails8-que/app/config/routes.rb
index f33e417ee..c2521424c 100644
--- a/ruby/rails8-que/app/config/routes.rb
+++ b/ruby/rails8-que/app/config/routes.rb
@@ -3,6 +3,7 @@
resources :workers do
collection do
get :queue
+ get :cross_service
end
end
diff --git a/ruby/rails8-que/app/processmon.downstream.toml b/ruby/rails8-que/app/processmon.downstream.toml
new file mode 100644
index 000000000..5b3a92acf
--- /dev/null
+++ b/ruby/rails8-que/app/processmon.downstream.toml
@@ -0,0 +1,26 @@
+[[paths_to_watch]]
+path = "/app"
+ignore = [
+ "tmp",
+ "log",
+ "node_modules",
+ "public/packs"
+]
+
+[[paths_to_watch]]
+path = "/integration"
+
+# Only the downstream service runs this worker, draining just the `downstream`
+# queue, so jobs enqueued by `app` are performed here under the downstream app
+# name. (`app`'s worker uses the default queue, so it never picks these up.)
+# No web server -- this service only runs jobs.
+[processes.que]
+command = "bundle"
+args = [
+ "exec",
+ "que",
+ "--queue-name",
+ "downstream",
+ "./config/environment.rb"
+]
+working_dir = "/app"
diff --git a/ruby/rails8-que/commands/run-downstream b/ruby/rails8-que/commands/run-downstream
new file mode 100755
index 000000000..fc89db176
--- /dev/null
+++ b/ruby/rails8-que/commands/run-downstream
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /app and /integration volumes. Wait for that instead of installing
+# concurrently, which would race on the shared vendor/bundle directory.
+echo "Waiting for the app service to install gems into the shared volume..."
+until bundle check >/dev/null 2>&1; do
+ echo "Gems not ready yet, retrying..."
+ sleep 2
+done
+
+echo "Starting downstream Que worker"
+/commands/processmon processmon.downstream.toml
diff --git a/ruby/rails8-que/docker-compose.agent.yml b/ruby/rails8-que/docker-compose.agent.yml
index 6ac437c3d..c3e07f4d4 100644
--- a/ruby/rails8-que/docker-compose.agent.yml
+++ b/ruby/rails8-que/docker-compose.agent.yml
@@ -5,3 +5,6 @@ services:
app:
environment:
- APPSIGNAL_APP_NAME=ruby/rails8-que
+ downstream:
+ environment:
+ - APPSIGNAL_APP_NAME=ruby/rails8-que-downstream
diff --git a/ruby/rails8-que/docker-compose.collector.yml b/ruby/rails8-que/docker-compose.collector.yml
index 153d35461..03606d9e6 100644
--- a/ruby/rails8-que/docker-compose.collector.yml
+++ b/ruby/rails8-que/docker-compose.collector.yml
@@ -7,3 +7,7 @@ services:
environment:
- APPSIGNAL_APP_NAME=ruby/rails8-que-collector
- APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099
+ downstream:
+ environment:
+ - APPSIGNAL_APP_NAME=ruby/rails8-que-downstream-collector
+ - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099
diff --git a/ruby/rails8-que/docker-compose.shared.yml b/ruby/rails8-que/docker-compose.shared.yml
index f039028d5..8bc1d316d 100644
--- a/ruby/rails8-que/docker-compose.shared.yml
+++ b/ruby/rails8-que/docker-compose.shared.yml
@@ -24,6 +24,22 @@ services:
volumes:
- ./app:/app
- ../integration:/integration
+ # A second instance of the same image, running as a distinct AppSignal app. It
+ # drains the `downstream` Que queue that `app` enqueues to, so the job's trace
+ # links back to the enqueuing app (job trace propagation across services).
+ downstream:
+ build: .
+ image: ruby/rails8-que
+ command: /commands/run-downstream
+ depends_on:
+ - postgres
+ env_file:
+ - postgres.env
+ - ../../appsignal.env
+ - ../../appsignal_key.env
+ volumes:
+ - ./app:/app
+ - ../integration:/integration
tests:
build: ../../support/server-tests
image: server-tests
From d1d72882f4b86f1fb92c61624899b0943ef297d0 Mon Sep 17 00:00:00 2001
From: Noemi Lapresta
Date: Thu, 9 Jul 2026 14:32:30 +0200
Subject: [PATCH 04/12] Cross-service Shoryuken propagation in rails6-shoryuken
Add a second instance of the image as a distinct "downstream" app that
drains the `downstream` SQS queue this app enqueues to. In collector
mode the trace context rides an SQS message attribute, so the job's
trace links back to the enqueue span across both apps. The existing
same-service worker (default/native queues) is unchanged.
The `downstream` queue is created in the mock SQS server by both
services (idempotent) so it exists regardless of boot order.
Exercises appsignal-ruby#1535.
---
ruby/rails6-shoryuken/app/Gemfile.lock | 2 +-
.../app/app/controllers/tests_controller.rb | 8 ++++++
.../app/app/views/tests/index.html.erb | 1 +
.../app/app/workers/downstream_worker.rb | 14 ++++++++++
.../app/config/initializers/shoryuken.rb | 1 +
ruby/rails6-shoryuken/app/config/routes.rb | 1 +
.../app/processmon.downstream.toml | 20 ++++++++++++++
ruby/rails6-shoryuken/commands/run | 3 +++
ruby/rails6-shoryuken/commands/run-downstream | 27 +++++++++++++++++++
.../rails6-shoryuken/docker-compose.agent.yml | 3 +++
.../docker-compose.collector.yml | 4 +++
.../docker-compose.shared.yml | 17 ++++++++++++
12 files changed, 100 insertions(+), 1 deletion(-)
create mode 100644 ruby/rails6-shoryuken/app/app/workers/downstream_worker.rb
create mode 100644 ruby/rails6-shoryuken/app/processmon.downstream.toml
create mode 100755 ruby/rails6-shoryuken/commands/run-downstream
diff --git a/ruby/rails6-shoryuken/app/Gemfile.lock b/ruby/rails6-shoryuken/app/Gemfile.lock
index cd45e09c7..e6c31203e 100644
--- a/ruby/rails6-shoryuken/app/Gemfile.lock
+++ b/ruby/rails6-shoryuken/app/Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: /integration
specs:
- appsignal (4.8.0)
+ appsignal (4.8.6)
logger
rack (>= 2.0.0)
diff --git a/ruby/rails6-shoryuken/app/app/controllers/tests_controller.rb b/ruby/rails6-shoryuken/app/app/controllers/tests_controller.rb
index 9868aeb30..c557f9dd1 100644
--- a/ruby/rails6-shoryuken/app/app/controllers/tests_controller.rb
+++ b/ruby/rails6-shoryuken/app/app/controllers/tests_controller.rb
@@ -31,4 +31,12 @@ def shoryuken_batched_job
end
render :html => "Batched Shoryuken jobs queued, refresh to queue new ones!"
end
+
+ # Enqueue a native Shoryuken job onto the `downstream` queue, which only the
+ # separately instrumented downstream service drains. In collector mode this
+ # shows job trace propagation across two services.
+ def cross_service_job
+ DownstreamWorker.perform_async(:body => "Cross-service job queued")
+ render :html => "Cross-service Shoryuken job queued, refresh to queue a new one!"
+ end
end
diff --git a/ruby/rails6-shoryuken/app/app/views/tests/index.html.erb b/ruby/rails6-shoryuken/app/app/views/tests/index.html.erb
index 2588b3362..6c5fcc6a1 100644
--- a/ruby/rails6-shoryuken/app/app/views/tests/index.html.erb
+++ b/ruby/rails6-shoryuken/app/app/views/tests/index.html.erb
@@ -7,4 +7,5 @@
<%= link_to "ActiveJob PerformanceJob", active_job_performance_job_path %>
<%= link_to "Native Shoryuken worker", shoryuken_native_job_path %>
<%= link_to "Batched Shoryuken worker", shoryuken_batched_job_path %>
+ <%= link_to "Cross-service Shoryuken worker", cross_service_job_path %>
diff --git a/ruby/rails6-shoryuken/app/app/workers/downstream_worker.rb b/ruby/rails6-shoryuken/app/app/workers/downstream_worker.rb
new file mode 100644
index 000000000..aecd71eb7
--- /dev/null
+++ b/ruby/rails6-shoryuken/app/app/workers/downstream_worker.rb
@@ -0,0 +1,14 @@
+# A native Shoryuken worker enqueued by this app but performed by the
+# separately-instrumented downstream service, which is the only worker draining
+# the `downstream` queue. In collector mode the trace context rides along on an
+# SQS message attribute, so the job's trace (under the downstream app) links back
+# to the enqueue span (under this app) -- job trace propagation across services.
+class DownstreamWorker
+ include Shoryuken::Worker
+
+ shoryuken_options :queue => "downstream", :auto_delete => true, :body_parser => :json
+
+ def perform(_sqs_msg, body)
+ puts "downstream performed Shoryuken job: #{body}"
+ end
+end
diff --git a/ruby/rails6-shoryuken/app/config/initializers/shoryuken.rb b/ruby/rails6-shoryuken/app/config/initializers/shoryuken.rb
index fb9ed965e..7a3350b67 100644
--- a/ruby/rails6-shoryuken/app/config/initializers/shoryuken.rb
+++ b/ruby/rails6-shoryuken/app/config/initializers/shoryuken.rb
@@ -23,4 +23,5 @@
# route the "native" queue to NativeWorker.
Rails.application.config.to_prepare do
NativeWorker
+ DownstreamWorker
end
diff --git a/ruby/rails6-shoryuken/app/config/routes.rb b/ruby/rails6-shoryuken/app/config/routes.rb
index 32e08a8f1..18f51893c 100644
--- a/ruby/rails6-shoryuken/app/config/routes.rb
+++ b/ruby/rails6-shoryuken/app/config/routes.rb
@@ -5,5 +5,6 @@
get "/active_job_error_job", to: "tests#active_job_error_job"
get "/shoryuken_native_job", to: "tests#shoryuken_native_job"
get "/shoryuken_batched_job", to: "tests#shoryuken_batched_job"
+ get "/cross_service_job", to: "tests#cross_service_job"
root :to => "tests#index"
end
diff --git a/ruby/rails6-shoryuken/app/processmon.downstream.toml b/ruby/rails6-shoryuken/app/processmon.downstream.toml
new file mode 100644
index 000000000..6d3a75ebb
--- /dev/null
+++ b/ruby/rails6-shoryuken/app/processmon.downstream.toml
@@ -0,0 +1,20 @@
+[[paths_to_watch]]
+path = "/app"
+ignore = ["tmp", "log", "public/packs", "node_modules/.cache"]
+
+[[paths_to_watch]]
+path = "/integration"
+
+# Only the downstream service runs this worker, draining just the `downstream`
+# queue, so jobs enqueued by `app` are performed here under the downstream app
+# name. (`app`'s worker uses the default/native queues, so it never picks these
+# up.) No web server -- this service only runs jobs.
+[processes.shoryuken]
+command = "bundle"
+args = [
+ "exec",
+ "shoryuken",
+ "-q=downstream",
+ "-R"
+]
+working_dir = "/app"
diff --git a/ruby/rails6-shoryuken/commands/run b/ruby/rails6-shoryuken/commands/run
index ca8f48a56..010a2e6d1 100755
--- a/ruby/rails6-shoryuken/commands/run
+++ b/ruby/rails6-shoryuken/commands/run
@@ -24,5 +24,8 @@ echo "Create sqs queues in mock server"
bundle exec shoryuken sqs create default
bundle exec shoryuken sqs create native
bundle exec shoryuken sqs create batched
+# Owned by the downstream service, but create it here too so the queue exists
+# when this app enqueues to it, regardless of the downstream service's timing.
+bundle exec shoryuken sqs create downstream
/commands/processmon processmon.toml
diff --git a/ruby/rails6-shoryuken/commands/run-downstream b/ruby/rails6-shoryuken/commands/run-downstream
new file mode 100755
index 000000000..df6f7e9d8
--- /dev/null
+++ b/ruby/rails6-shoryuken/commands/run-downstream
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# Point bundler at the shared vendor/bundle the `app` service installs into.
+# (This app configures the path at runtime rather than via a BUNDLE_PATH env, so
+# each container must set it for itself.)
+bundle config set path vendor/bundle
+
+# The `app` service owns installing gems and running migrations into the shared
+# /app and /integration volumes. Wait for that instead of installing
+# concurrently, which would race on the shared vendor/bundle directory.
+echo "Waiting for the app service to install gems into the shared volume..."
+until bundle check >/dev/null 2>&1; do
+ echo "Gems not ready yet, retrying..."
+ sleep 2
+done
+
+# Make sure the queue exists before this worker starts polling it (the `app`
+# service also creates it; SQS CreateQueue is idempotent).
+echo "Ensuring the downstream SQS queue exists"
+bundle exec shoryuken sqs create downstream || true
+
+echo "Starting downstream Shoryuken worker"
+/commands/processmon processmon.downstream.toml
diff --git a/ruby/rails6-shoryuken/docker-compose.agent.yml b/ruby/rails6-shoryuken/docker-compose.agent.yml
index b6ca95b17..743403932 100644
--- a/ruby/rails6-shoryuken/docker-compose.agent.yml
+++ b/ruby/rails6-shoryuken/docker-compose.agent.yml
@@ -5,3 +5,6 @@ services:
app:
environment:
- APPSIGNAL_APP_NAME=rails6-shoryuken
+ downstream:
+ environment:
+ - APPSIGNAL_APP_NAME=rails6-shoryuken-downstream
diff --git a/ruby/rails6-shoryuken/docker-compose.collector.yml b/ruby/rails6-shoryuken/docker-compose.collector.yml
index baa91282f..27263e9bc 100644
--- a/ruby/rails6-shoryuken/docker-compose.collector.yml
+++ b/ruby/rails6-shoryuken/docker-compose.collector.yml
@@ -7,3 +7,7 @@ services:
environment:
- APPSIGNAL_APP_NAME=rails6-shoryuken-collector
- APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099
+ downstream:
+ environment:
+ - APPSIGNAL_APP_NAME=rails6-shoryuken-downstream-collector
+ - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099
diff --git a/ruby/rails6-shoryuken/docker-compose.shared.yml b/ruby/rails6-shoryuken/docker-compose.shared.yml
index d2df5cfc0..ffad40c4c 100644
--- a/ruby/rails6-shoryuken/docker-compose.shared.yml
+++ b/ruby/rails6-shoryuken/docker-compose.shared.yml
@@ -23,6 +23,23 @@ services:
volumes:
- ./app:/app
- ../integration:/integration
+ # A second instance of the same image, running as a distinct AppSignal app. It
+ # drains the `downstream` SQS queue that `app` enqueues to, so the job's trace
+ # links back to the enqueuing app (job trace propagation across services).
+ downstream:
+ build: .
+ platform: "linux/amd64"
+ image: ruby/rails6-shoryuken
+ command: /commands/run-downstream
+ depends_on:
+ - motoserver
+ env_file:
+ - shoryuken.env
+ - ../../appsignal.env
+ - ../../appsignal_key.env
+ volumes:
+ - ./app:/app
+ - ../integration:/integration
tests:
build: ../../support/server-tests
image: server-tests
From 66d776b9458ae1fae2da0b194fea808983d581a7 Mon Sep 17 00:00:00 2001
From: Noemi Lapresta
Date: Thu, 9 Jul 2026 14:41:57 +0200
Subject: [PATCH 05/12] Cross-service Webmachine trace propagation in
webmachine2
Add a second instance of the image on port 4002 as a distinct
"downstream" app, and a call_downstream resource that makes an HTTP
request to it. In collector mode the Net::HTTP integration injects the
trace context and the downstream app extracts it through Webmachine's
own (non-Rack) extraction path, so the trace spans both apps. This is
the one HTTP-server extraction path not shared with the Rack
integrations.
Exercises appsignal-ruby#1535.
---
ruby/webmachine2/app/Gemfile.lock | 2 +-
ruby/webmachine2/app/app.rb | 18 +++++++++++++++
ruby/webmachine2/commands/run-downstream | 22 +++++++++++++++++++
ruby/webmachine2/docker-compose.agent.yml | 3 +++
ruby/webmachine2/docker-compose.collector.yml | 4 ++++
ruby/webmachine2/docker-compose.shared.yml | 19 ++++++++++++++++
6 files changed, 67 insertions(+), 1 deletion(-)
create mode 100755 ruby/webmachine2/commands/run-downstream
diff --git a/ruby/webmachine2/app/Gemfile.lock b/ruby/webmachine2/app/Gemfile.lock
index e0414c5cb..8d549429e 100644
--- a/ruby/webmachine2/app/Gemfile.lock
+++ b/ruby/webmachine2/app/Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: /integration
specs:
- appsignal (4.8.0)
+ appsignal (4.8.6)
logger
rack (>= 2.0.0)
diff --git a/ruby/webmachine2/app/app.rb b/ruby/webmachine2/app/app.rb
index 1b4aa7dfd..28636fe19 100644
--- a/ruby/webmachine2/app/app.rb
+++ b/ruby/webmachine2/app/app.rb
@@ -1,4 +1,5 @@
require "webmachine"
+require "net/http"
require "appsignal"
Appsignal.start
@@ -27,6 +28,21 @@ def to_html
end
end
+# Makes an HTTP request to a second, separately-instrumented webmachine app (the
+# `downstream` service). AppSignal's Net::HTTP integration injects the current
+# W3C trace context onto the request in collector mode, and the downstream app's
+# Webmachine integration extracts it -- so the trace spans both apps. This
+# exercises Webmachine's trace-context extraction, the one server path that is
+# not shared with the Rack integrations. Falls back to calling itself when
+# DOWNSTREAM_URL is unset.
+class CallDownstreamResource < Webmachine::Resource
+ def to_html
+ url = ENV.fetch("DOWNSTREAM_URL", "http://localhost:4001")
+ Net::HTTP.get(URI("#{url}/"))
+ "Called downstream app"
+ end
+end
+
class MyRootResource < Webmachine::Resource
def to_html
time = Time.now.utc
@@ -38,6 +54,7 @@ def to_html