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.

+ + +

Sidekiq monitoring page

+ +

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.

+ + 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.

    + + 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 @@ -48,5 +65,6 @@ def to_html Webmachine.application.routes do add ["slow"], MyPerformanceResource add ["error"], MyErrorResource + add ["call_downstream"], CallDownstreamResource add [], MyRootResource end diff --git a/ruby/webmachine2/commands/run-downstream b/ruby/webmachine2/commands/run-downstream new file mode 100755 index 000000000..4d6d25395 --- /dev/null +++ b/ruby/webmachine2/commands/run-downstream @@ -0,0 +1,22 @@ +#!/bin/bash + +set -eu + +cd /app + +# Point bundler at the shared vendor/bundle the `app` service installs into, so +# this container can find the gems (each container configures bundler itself). +bundle config set --local path vendor/bundle + +# The `app` service installs the gems and builds the extension into the shared +# volumes. Wait for that instead of installing concurrently, which would race on +# vendor/bundle. +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 + +# Runs the same webmachine app, which binds to PORT (4002 here) via config.ru. +echo "Starting downstream webmachine app" +/commands/processmon processmon.toml diff --git a/ruby/webmachine2/docker-compose.agent.yml b/ruby/webmachine2/docker-compose.agent.yml index 8b7d8c5c5..92c8c9684 100644 --- a/ruby/webmachine2/docker-compose.agent.yml +++ b/ruby/webmachine2/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/webmachine2 + downstream: + environment: + - APPSIGNAL_APP_NAME=ruby/webmachine2-downstream diff --git a/ruby/webmachine2/docker-compose.collector.yml b/ruby/webmachine2/docker-compose.collector.yml index 726558df3..0bb95f218 100644 --- a/ruby/webmachine2/docker-compose.collector.yml +++ b/ruby/webmachine2/docker-compose.collector.yml @@ -7,3 +7,7 @@ services: environment: - APPSIGNAL_APP_NAME=ruby/webmachine2-collector - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + downstream: + environment: + - APPSIGNAL_APP_NAME=ruby/webmachine2-downstream-collector + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/webmachine2/docker-compose.shared.yml b/ruby/webmachine2/docker-compose.shared.yml index 0ffabde9a..b0c5b6b0d 100644 --- a/ruby/webmachine2/docker-compose.shared.yml +++ b/ruby/webmachine2/docker-compose.shared.yml @@ -11,6 +11,25 @@ services: - "4001:4001" environment: - PORT=4001 + # The call_downstream resource makes an HTTP request to this second, + # separately-instrumented webmachine app so the trace spans both. + - DOWNSTREAM_URL=http://downstream:4002 + env_file: + - ../../appsignal.env + - ../../appsignal_key.env + volumes: + - ./app:/app + - ../integration:/integration + # A second instance of the same image on port 4002, running as a distinct + # AppSignal app. It receives the HTTP request `app` makes and extracts the + # trace context (Webmachine's own, non-Rack extraction path), continuing the + # trace across both apps. + downstream: + build: . + image: ruby/webmachine + command: /commands/run-downstream + environment: + - PORT=4002 env_file: - ../../appsignal.env - ../../appsignal_key.env From 135b902092162ebecbf83eb98c5a3c06c4bf639b Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 13 Jul 2026 12:27:21 +0200 Subject: [PATCH 06/12] Set an explicit collector service name in all setups In collector mode both the AppSignal Ruby gem and Python package read APPSIGNAL_SERVICE_NAME and send it as the OpenTelemetry `service.name` resource attribute. AppSignal groups traces into a `/` namespace based on it. When the variable is unset the service name falls back to "unknown". Set it explicitly to "app" for every collector setup so none of them report an "unknown" service. The cross-service setups additionally name their second service "downstream", which sets up the next change: sharing a single app name across the two services in collector mode. --- python/django4-asgi/docker-compose.collector.yml | 1 + python/django4-celery/docker-compose.collector.yml | 1 + python/django4-wsgi/docker-compose.collector.yml | 1 + python/django5-celery/docker-compose.collector.yml | 1 + python/fastapi-databases/docker-compose.collector.yml | 1 + python/fastapi/docker-compose.collector.yml | 1 + python/flask-pika/docker-compose.collector.yml | 1 + python/flask-sqlalchemy/docker-compose.collector.yml | 1 + python/flask/docker-compose.collector.yml | 1 + python/starlette/docker-compose.collector.yml | 1 + ruby/grape/docker-compose.collector.yml | 1 + ruby/hanami2-postgres/docker-compose.collector.yml | 1 + ruby/rack/docker-compose.collector.yml | 1 + ruby/rails6-mysql/docker-compose.collector.yml | 1 + ruby/rails6-shakapacker/docker-compose.collector.yml | 1 + ruby/rails6-shoryuken/docker-compose.collector.yml | 2 ++ ruby/rails7-delayed-job/docker-compose.collector.yml | 1 + ruby/rails7-goodjob/docker-compose.collector.yml | 1 + ruby/rails7-postgres/docker-compose.collector.yml | 1 + ruby/rails7-sequel/docker-compose.collector.yml | 1 + ruby/rails7-sidekiq/docker-compose.collector.yml | 2 ++ ruby/rails7-solid-cache/docker-compose.collector.yml | 1 + ruby/rails7-solid-queue/docker-compose.collector.yml | 1 + ruby/rails8-delayed-job/docker-compose.collector.yml | 1 + ruby/rails8-que/docker-compose.collector.yml | 2 ++ ruby/rails8-resque/docker-compose.collector.yml | 2 ++ ruby/rails8-sidekiq/docker-compose.collector.yml | 1 + ruby/sinatra-alpine/docker-compose.collector.yml | 1 + ruby/sinatra-gvltools/docker-compose.collector.yml | 1 + ruby/webmachine1/docker-compose.collector.yml | 1 + ruby/webmachine2/docker-compose.collector.yml | 2 ++ 31 files changed, 36 insertions(+) diff --git a/python/django4-asgi/docker-compose.collector.yml b/python/django4-asgi/docker-compose.collector.yml index db91024e3..d6e0346a5 100644 --- a/python/django4-asgi/docker-compose.collector.yml +++ b/python/django4-asgi/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/django4-asgi-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django4-celery/docker-compose.collector.yml b/python/django4-celery/docker-compose.collector.yml index e30d90ef7..15e57d735 100644 --- a/python/django4-celery/docker-compose.collector.yml +++ b/python/django4-celery/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/django4-celery-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django4-wsgi/docker-compose.collector.yml b/python/django4-wsgi/docker-compose.collector.yml index d3c6d9504..aabf1c1ce 100644 --- a/python/django4-wsgi/docker-compose.collector.yml +++ b/python/django4-wsgi/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/django4-wsgi-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django5-celery/docker-compose.collector.yml b/python/django5-celery/docker-compose.collector.yml index 932dac9a5..6a10728c1 100644 --- a/python/django5-celery/docker-compose.collector.yml +++ b/python/django5-celery/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/django5-celery-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/fastapi-databases/docker-compose.collector.yml b/python/fastapi-databases/docker-compose.collector.yml index a7d4d6f22..f7838f562 100644 --- a/python/fastapi-databases/docker-compose.collector.yml +++ b/python/fastapi-databases/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/fastapi-databases-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/fastapi/docker-compose.collector.yml b/python/fastapi/docker-compose.collector.yml index 9fb6468a4..31354247b 100644 --- a/python/fastapi/docker-compose.collector.yml +++ b/python/fastapi/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/fastapi-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/flask-pika/docker-compose.collector.yml b/python/flask-pika/docker-compose.collector.yml index c24ef4fce..973390fbf 100644 --- a/python/flask-pika/docker-compose.collector.yml +++ b/python/flask-pika/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/flask-pika-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/flask-sqlalchemy/docker-compose.collector.yml b/python/flask-sqlalchemy/docker-compose.collector.yml index d0d5d1b64..d3b08022b 100644 --- a/python/flask-sqlalchemy/docker-compose.collector.yml +++ b/python/flask-sqlalchemy/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/flask-sqlalchemy-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/flask/docker-compose.collector.yml b/python/flask/docker-compose.collector.yml index 63e9c0b22..7be2ca6fb 100644 --- a/python/flask/docker-compose.collector.yml +++ b/python/flask/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/flask-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/starlette/docker-compose.collector.yml b/python/starlette/docker-compose.collector.yml index 6609b2fb5..efcb78225 100644 --- a/python/starlette/docker-compose.collector.yml +++ b/python/starlette/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/starlette-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/grape/docker-compose.collector.yml b/ruby/grape/docker-compose.collector.yml index 399479e47..a7905da74 100644 --- a/ruby/grape/docker-compose.collector.yml +++ b/ruby/grape/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/grape-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/hanami2-postgres/docker-compose.collector.yml b/ruby/hanami2-postgres/docker-compose.collector.yml index 18dd62592..a05e7758f 100644 --- a/ruby/hanami2-postgres/docker-compose.collector.yml +++ b/ruby/hanami2-postgres/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-hanami2-postgres-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rack/docker-compose.collector.yml b/ruby/rack/docker-compose.collector.yml index 8ade7cada..6695ba420 100644 --- a/ruby/rack/docker-compose.collector.yml +++ b/ruby/rack/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/rack-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails6-mysql/docker-compose.collector.yml b/ruby/rails6-mysql/docker-compose.collector.yml index a17f41c8c..91c62f6cc 100644 --- a/ruby/rails6-mysql/docker-compose.collector.yml +++ b/ruby/rails6-mysql/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails6-mysql-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails6-shakapacker/docker-compose.collector.yml b/ruby/rails6-shakapacker/docker-compose.collector.yml index f13c9a20a..ab23d8511 100644 --- a/ruby/rails6-shakapacker/docker-compose.collector.yml +++ b/ruby/rails6-shakapacker/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails6-shakapacker-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails6-shoryuken/docker-compose.collector.yml b/ruby/rails6-shoryuken/docker-compose.collector.yml index 27263e9bc..fd8741f5a 100644 --- a/ruby/rails6-shoryuken/docker-compose.collector.yml +++ b/ruby/rails6-shoryuken/docker-compose.collector.yml @@ -6,8 +6,10 @@ services: app: environment: - APPSIGNAL_APP_NAME=rails6-shoryuken-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 downstream: environment: - APPSIGNAL_APP_NAME=rails6-shoryuken-downstream-collector + - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-delayed-job/docker-compose.collector.yml b/ruby/rails7-delayed-job/docker-compose.collector.yml index 9d25d7abc..882d85891 100644 --- a/ruby/rails7-delayed-job/docker-compose.collector.yml +++ b/ruby/rails7-delayed-job/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails7-delayed-job-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-goodjob/docker-compose.collector.yml b/ruby/rails7-goodjob/docker-compose.collector.yml index 501050d69..ba3e4fdee 100644 --- a/ruby/rails7-goodjob/docker-compose.collector.yml +++ b/ruby/rails7-goodjob/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails7-goodjob-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-postgres/docker-compose.collector.yml b/ruby/rails7-postgres/docker-compose.collector.yml index 938d30519..2939c4621 100644 --- a/ruby/rails7-postgres/docker-compose.collector.yml +++ b/ruby/rails7-postgres/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails7-postgres-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-sequel/docker-compose.collector.yml b/ruby/rails7-sequel/docker-compose.collector.yml index bcf685cda..3c854c92c 100644 --- a/ruby/rails7-sequel/docker-compose.collector.yml +++ b/ruby/rails7-sequel/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails7-sequel-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-sidekiq/docker-compose.collector.yml b/ruby/rails7-sidekiq/docker-compose.collector.yml index 06ca58f13..f074865f8 100644 --- a/ruby/rails7-sidekiq/docker-compose.collector.yml +++ b/ruby/rails7-sidekiq/docker-compose.collector.yml @@ -6,8 +6,10 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 downstream: environment: - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-downstream-collector + - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-solid-cache/docker-compose.collector.yml b/ruby/rails7-solid-cache/docker-compose.collector.yml index 8adbed8c1..244a7098d 100644 --- a/ruby/rails7-solid-cache/docker-compose.collector.yml +++ b/ruby/rails7-solid-cache/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails7-solid-cache-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-solid-queue/docker-compose.collector.yml b/ruby/rails7-solid-queue/docker-compose.collector.yml index ef77bbc19..0f616870d 100644 --- a/ruby/rails7-solid-queue/docker-compose.collector.yml +++ b/ruby/rails7-solid-queue/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails7-solid-queue-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails8-delayed-job/docker-compose.collector.yml b/ruby/rails8-delayed-job/docker-compose.collector.yml index 0406d0325..0c2b7c9f1 100644 --- a/ruby/rails8-delayed-job/docker-compose.collector.yml +++ b/ruby/rails8-delayed-job/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/rails8-delayed-job-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails8-que/docker-compose.collector.yml b/ruby/rails8-que/docker-compose.collector.yml index 03606d9e6..dbd626ec7 100644 --- a/ruby/rails8-que/docker-compose.collector.yml +++ b/ruby/rails8-que/docker-compose.collector.yml @@ -6,8 +6,10 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/rails8-que-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 downstream: environment: - APPSIGNAL_APP_NAME=ruby/rails8-que-downstream-collector + - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails8-resque/docker-compose.collector.yml b/ruby/rails8-resque/docker-compose.collector.yml index bb3a7f7dc..b7be0570c 100644 --- a/ruby/rails8-resque/docker-compose.collector.yml +++ b/ruby/rails8-resque/docker-compose.collector.yml @@ -6,8 +6,10 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/rails8-resque-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 downstream: environment: - APPSIGNAL_APP_NAME=ruby/rails8-resque-downstream-collector + - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails8-sidekiq/docker-compose.collector.yml b/ruby/rails8-sidekiq/docker-compose.collector.yml index e35609e07..0abf6069c 100644 --- a/ruby/rails8-sidekiq/docker-compose.collector.yml +++ b/ruby/rails8-sidekiq/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/rails8-sidekiq-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/sinatra-alpine/docker-compose.collector.yml b/ruby/sinatra-alpine/docker-compose.collector.yml index 953022b24..27672a9e2 100644 --- a/ruby/sinatra-alpine/docker-compose.collector.yml +++ b/ruby/sinatra-alpine/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-sinatra-alpine-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/sinatra-gvltools/docker-compose.collector.yml b/ruby/sinatra-gvltools/docker-compose.collector.yml index 684e0f17e..325e12651 100644 --- a/ruby/sinatra-gvltools/docker-compose.collector.yml +++ b/ruby/sinatra-gvltools/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-sinatra-gvltools-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/webmachine1/docker-compose.collector.yml b/ruby/webmachine1/docker-compose.collector.yml index c4c726055..fd4117e3c 100644 --- a/ruby/webmachine1/docker-compose.collector.yml +++ b/ruby/webmachine1/docker-compose.collector.yml @@ -6,4 +6,5 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/webmachine1-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/webmachine2/docker-compose.collector.yml b/ruby/webmachine2/docker-compose.collector.yml index 0bb95f218..681ddd67b 100644 --- a/ruby/webmachine2/docker-compose.collector.yml +++ b/ruby/webmachine2/docker-compose.collector.yml @@ -6,8 +6,10 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/webmachine2-collector + - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 downstream: environment: - APPSIGNAL_APP_NAME=ruby/webmachine2-downstream-collector + - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 From 27a03dc7e367bbf15c2dc28ef30a8bc4c8ac4b5c Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 13 Jul 2026 12:27:56 +0200 Subject: [PATCH 07/12] Share one app across cross-service collector pairs The cross-service setups run a second `downstream` service to exercise trace propagation between two separately-instrumented processes. In agent mode those two must stay separate AppSignal apps, because the agent has no service name concept and an app is the only identity it reports. Collector mode does have a service name, so model the pair the way it really is: one application made of two services. Point both services at the same APPSIGNAL_APP_NAME and let APPSIGNAL_SERVICE_NAME (app and downstream) tell them apart. The trace then propagates across services within a single app instead of spanning two apps. --- ruby/rails6-shoryuken/docker-compose.collector.yml | 2 +- ruby/rails7-sidekiq/docker-compose.collector.yml | 2 +- ruby/rails8-que/docker-compose.collector.yml | 2 +- ruby/rails8-resque/docker-compose.collector.yml | 2 +- ruby/webmachine2/docker-compose.collector.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ruby/rails6-shoryuken/docker-compose.collector.yml b/ruby/rails6-shoryuken/docker-compose.collector.yml index fd8741f5a..76f3d3260 100644 --- a/ruby/rails6-shoryuken/docker-compose.collector.yml +++ b/ruby/rails6-shoryuken/docker-compose.collector.yml @@ -10,6 +10,6 @@ services: - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 downstream: environment: - - APPSIGNAL_APP_NAME=rails6-shoryuken-downstream-collector + - APPSIGNAL_APP_NAME=rails6-shoryuken-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-sidekiq/docker-compose.collector.yml b/ruby/rails7-sidekiq/docker-compose.collector.yml index f074865f8..3df4f1513 100644 --- a/ruby/rails7-sidekiq/docker-compose.collector.yml +++ b/ruby/rails7-sidekiq/docker-compose.collector.yml @@ -10,6 +10,6 @@ services: - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 downstream: environment: - - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-downstream-collector + - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails8-que/docker-compose.collector.yml b/ruby/rails8-que/docker-compose.collector.yml index dbd626ec7..4396cc76f 100644 --- a/ruby/rails8-que/docker-compose.collector.yml +++ b/ruby/rails8-que/docker-compose.collector.yml @@ -10,6 +10,6 @@ services: - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 downstream: environment: - - APPSIGNAL_APP_NAME=ruby/rails8-que-downstream-collector + - APPSIGNAL_APP_NAME=ruby/rails8-que-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails8-resque/docker-compose.collector.yml b/ruby/rails8-resque/docker-compose.collector.yml index b7be0570c..c8304f19d 100644 --- a/ruby/rails8-resque/docker-compose.collector.yml +++ b/ruby/rails8-resque/docker-compose.collector.yml @@ -10,6 +10,6 @@ services: - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 downstream: environment: - - APPSIGNAL_APP_NAME=ruby/rails8-resque-downstream-collector + - APPSIGNAL_APP_NAME=ruby/rails8-resque-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/webmachine2/docker-compose.collector.yml b/ruby/webmachine2/docker-compose.collector.yml index 681ddd67b..02cec5089 100644 --- a/ruby/webmachine2/docker-compose.collector.yml +++ b/ruby/webmachine2/docker-compose.collector.yml @@ -10,6 +10,6 @@ services: - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 downstream: environment: - - APPSIGNAL_APP_NAME=ruby/webmachine2-downstream-collector + - APPSIGNAL_APP_NAME=ruby/webmachine2-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 From 015aac3301a15214ecfcb975cd93736bf8c07403 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 13 Jul 2026 15:18:30 +0200 Subject: [PATCH 08/12] Split the background worker into its own service In every Ruby setup that runs background jobs, the worker ran as a second process inside the `app` container, next to the web server. Move it into a dedicated `worker` container that runs the same image. This mirrors a real web/worker deployment, and it attributes job traces to a separate identity from the web requests. In agent mode the worker reports to a `-worker` app. In collector mode it shares the app name and sets `APPSIGNAL_SERVICE_NAME=worker`, so its jobs group under a `worker` service. The `worker` service is image-only; only `app` declares `build:`. When two services build the same image tag, `docker compose build` fails on Docker's containerd image store with an "image already exists" error. For the same reason the existing `downstream` services are now image-only too. Bare `ruby/shoryuken` is left out because it has no agent or collector mode files to carry the worker's identity. --- ruby/rails6-shoryuken/app/processmon.toml | 12 --------- .../app/processmon.worker.toml | 22 +++++++++++++++ ruby/rails6-shoryuken/commands/run-worker | 18 +++++++++++++ .../rails6-shoryuken/docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ .../docker-compose.shared.yml | 20 +++++++++++++- ruby/rails7-delayed-job/app/processmon.toml | 7 ----- .../app/processmon.worker.toml | 22 +++++++++++++++ ruby/rails7-delayed-job/commands/run-worker | 18 +++++++++++++ .../docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ .../docker-compose.shared.yml | 14 ++++++++++ ruby/rails7-goodjob/app/processmon.toml | 9 ------- .../rails7-goodjob/app/processmon.worker.toml | 24 +++++++++++++++++ ruby/rails7-goodjob/commands/run-worker | 18 +++++++++++++ ruby/rails7-goodjob/docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ ruby/rails7-goodjob/docker-compose.shared.yml | 15 +++++++++++ ruby/rails7-sidekiq/app/processmon.toml | 9 ------- .../rails7-sidekiq/app/processmon.worker.toml | 24 +++++++++++++++++ ruby/rails7-sidekiq/commands/run-worker | 18 +++++++++++++ ruby/rails7-sidekiq/docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ ruby/rails7-sidekiq/docker-compose.shared.yml | 27 ++++++++++++++++++- ruby/rails7-solid-queue/app/processmon.toml | 7 ----- .../app/processmon.worker.toml | 22 +++++++++++++++ ruby/rails7-solid-queue/commands/run-worker | 18 +++++++++++++ .../docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ .../docker-compose.shared.yml | 14 ++++++++++ ruby/rails8-delayed-job/app/processmon.toml | 7 ----- .../app/processmon.worker.toml | 23 ++++++++++++++++ ruby/rails8-delayed-job/commands/run-worker | 18 +++++++++++++ .../docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ .../docker-compose.shared.yml | 15 +++++++++++ ruby/rails8-que/app/processmon.toml | 9 ------- ruby/rails8-que/app/processmon.worker.toml | 24 +++++++++++++++++ ruby/rails8-que/commands/run-worker | 18 +++++++++++++ ruby/rails8-que/docker-compose.agent.yml | 3 +++ ruby/rails8-que/docker-compose.collector.yml | 5 ++++ ruby/rails8-que/docker-compose.shared.yml | 19 ++++++++++++- ruby/rails8-resque/app/processmon.toml | 9 ------- ruby/rails8-resque/app/processmon.worker.toml | 24 +++++++++++++++++ ruby/rails8-resque/commands/run-worker | 18 +++++++++++++ ruby/rails8-resque/docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ ruby/rails8-resque/docker-compose.shared.yml | 19 ++++++++++++- ruby/rails8-sidekiq/app/processmon.toml | 9 ------- .../rails8-sidekiq/app/processmon.worker.toml | 24 +++++++++++++++++ ruby/rails8-sidekiq/commands/run-worker | 18 +++++++++++++ ruby/rails8-sidekiq/docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ ruby/rails8-sidekiq/docker-compose.shared.yml | 22 +++++++++++++++ 54 files changed, 604 insertions(+), 82 deletions(-) create mode 100644 ruby/rails6-shoryuken/app/processmon.worker.toml create mode 100755 ruby/rails6-shoryuken/commands/run-worker create mode 100644 ruby/rails7-delayed-job/app/processmon.worker.toml create mode 100755 ruby/rails7-delayed-job/commands/run-worker create mode 100644 ruby/rails7-goodjob/app/processmon.worker.toml create mode 100755 ruby/rails7-goodjob/commands/run-worker create mode 100644 ruby/rails7-sidekiq/app/processmon.worker.toml create mode 100755 ruby/rails7-sidekiq/commands/run-worker create mode 100644 ruby/rails7-solid-queue/app/processmon.worker.toml create mode 100755 ruby/rails7-solid-queue/commands/run-worker create mode 100644 ruby/rails8-delayed-job/app/processmon.worker.toml create mode 100755 ruby/rails8-delayed-job/commands/run-worker create mode 100644 ruby/rails8-que/app/processmon.worker.toml create mode 100755 ruby/rails8-que/commands/run-worker create mode 100644 ruby/rails8-resque/app/processmon.worker.toml create mode 100755 ruby/rails8-resque/commands/run-worker create mode 100644 ruby/rails8-sidekiq/app/processmon.worker.toml create mode 100755 ruby/rails8-sidekiq/commands/run-worker diff --git a/ruby/rails6-shoryuken/app/processmon.toml b/ruby/rails6-shoryuken/app/processmon.toml index 072c35113..22455f20e 100644 --- a/ruby/rails6-shoryuken/app/processmon.toml +++ b/ruby/rails6-shoryuken/app/processmon.toml @@ -5,18 +5,6 @@ ignore = ["tmp", "log", "public/packs", "node_modules/.cache"] [[paths_to_watch]] path = "/integration" -[processes.shoryuken] -command = "bundle" -args = [ - "exec", - "shoryuken", - "-q=default", - "-q=native", - "-q=batched", - "-R" -] -working_dir = "/app" - [processes.rails] command = "bin/rails" args = [ diff --git a/ruby/rails6-shoryuken/app/processmon.worker.toml b/ruby/rails6-shoryuken/app/processmon.worker.toml new file mode 100644 index 000000000..516c2c28b --- /dev/null +++ b/ruby/rails6-shoryuken/app/processmon.worker.toml @@ -0,0 +1,22 @@ +[[paths_to_watch]] +path = "/app" +ignore = ["tmp", "log", "public/packs", "node_modules/.cache"] + +[[paths_to_watch]] +path = "/integration" + +# The worker service runs only the background job processor. Jobs enqueued by +# the web `app` service are performed here, so the job's trace runs under the +# worker's own app name (agent mode) or `worker` service name (collector mode), +# linked back to the enqueue span in `app`. +[processes.shoryuken] +command = "bundle" +args = [ + "exec", + "shoryuken", + "-q=default", + "-q=native", + "-q=batched", + "-R" +] +working_dir = "/app" diff --git a/ruby/rails6-shoryuken/commands/run-worker b/ruby/rails6-shoryuken/commands/run-worker new file mode 100755 index 000000000..1bd5ac710 --- /dev/null +++ b/ruby/rails6-shoryuken/commands/run-worker @@ -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 worker processes" +/commands/processmon processmon.worker.toml diff --git a/ruby/rails6-shoryuken/docker-compose.agent.yml b/ruby/rails6-shoryuken/docker-compose.agent.yml index 743403932..47eb4701d 100644 --- a/ruby/rails6-shoryuken/docker-compose.agent.yml +++ b/ruby/rails6-shoryuken/docker-compose.agent.yml @@ -8,3 +8,6 @@ services: downstream: environment: - APPSIGNAL_APP_NAME=rails6-shoryuken-downstream + worker: + environment: + - APPSIGNAL_APP_NAME=rails6-shoryuken-worker diff --git a/ruby/rails6-shoryuken/docker-compose.collector.yml b/ruby/rails6-shoryuken/docker-compose.collector.yml index 76f3d3260..467cbc61f 100644 --- a/ruby/rails6-shoryuken/docker-compose.collector.yml +++ b/ruby/rails6-shoryuken/docker-compose.collector.yml @@ -13,3 +13,8 @@ services: - APPSIGNAL_APP_NAME=rails6-shoryuken-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=rails6-shoryuken-collector + - APPSIGNAL_SERVICE_NAME=worker + - 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 ffad40c4c..e8a1aecef 100644 --- a/ruby/rails6-shoryuken/docker-compose.shared.yml +++ b/ruby/rails6-shoryuken/docker-compose.shared.yml @@ -27,7 +27,6 @@ services: # 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 @@ -40,6 +39,25 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (see run-worker / processmon.worker.toml). + # Image-only (no build:) so `docker compose build` does not collide with the + # app service exporting the same image tag on Docker's containerd store. + worker: + platform: "linux/amd64" + image: ruby/rails6-shoryuken + command: /commands/run-worker + depends_on: + - motoserver + env_file: + - shoryuken.env + - ../../appsignal.env + - ../../appsignal_key.env + 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). tests: build: ../../support/server-tests image: server-tests diff --git a/ruby/rails7-delayed-job/app/processmon.toml b/ruby/rails7-delayed-job/app/processmon.toml index 2ddbdc901..4018daf2e 100644 --- a/ruby/rails7-delayed-job/app/processmon.toml +++ b/ruby/rails7-delayed-job/app/processmon.toml @@ -10,13 +10,6 @@ ignore = [ [[paths_to_watch]] path = "/integration" -[processes.delayed_job] -command = "bin/rake" -args = [ - "jobs:work" -] -working_dir = "/app" - [processes.rails] command = "bin/rails" args = [ diff --git a/ruby/rails7-delayed-job/app/processmon.worker.toml b/ruby/rails7-delayed-job/app/processmon.worker.toml new file mode 100644 index 000000000..9d40330d0 --- /dev/null +++ b/ruby/rails7-delayed-job/app/processmon.worker.toml @@ -0,0 +1,22 @@ +[[paths_to_watch]] +path = "/app" +ignore = [ + "tmp", + "log", + "node_modules", + "public/packs" +] + +[[paths_to_watch]] +path = "/integration" + +# The worker service runs only the background job processor. Jobs enqueued by +# the web `app` service are performed here, so the job's trace runs under the +# worker's own app name (agent mode) or `worker` service name (collector mode), +# linked back to the enqueue span in `app`. +[processes.delayed_job] +command = "bin/rake" +args = [ + "jobs:work" +] +working_dir = "/app" diff --git a/ruby/rails7-delayed-job/commands/run-worker b/ruby/rails7-delayed-job/commands/run-worker new file mode 100755 index 000000000..1bd5ac710 --- /dev/null +++ b/ruby/rails7-delayed-job/commands/run-worker @@ -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 worker processes" +/commands/processmon processmon.worker.toml diff --git a/ruby/rails7-delayed-job/docker-compose.agent.yml b/ruby/rails7-delayed-job/docker-compose.agent.yml index 00e76013a..6e00a9870 100644 --- a/ruby/rails7-delayed-job/docker-compose.agent.yml +++ b/ruby/rails7-delayed-job/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails7-delayed-job + worker: + environment: + - APPSIGNAL_APP_NAME=ruby-rails7-delayed-job-worker diff --git a/ruby/rails7-delayed-job/docker-compose.collector.yml b/ruby/rails7-delayed-job/docker-compose.collector.yml index 882d85891..13d1f7bbd 100644 --- a/ruby/rails7-delayed-job/docker-compose.collector.yml +++ b/ruby/rails7-delayed-job/docker-compose.collector.yml @@ -8,3 +8,8 @@ services: - APPSIGNAL_APP_NAME=ruby-rails7-delayed-job-collector - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=ruby-rails7-delayed-job-collector + - APPSIGNAL_SERVICE_NAME=worker + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-delayed-job/docker-compose.shared.yml b/ruby/rails7-delayed-job/docker-compose.shared.yml index af9c763bb..5f3f78710 100644 --- a/ruby/rails7-delayed-job/docker-compose.shared.yml +++ b/ruby/rails7-delayed-job/docker-compose.shared.yml @@ -23,6 +23,20 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (see run-worker / processmon.worker.toml). + # Image-only (no build:) so `docker compose build` does not collide with the + # app service exporting the same image tag on Docker's containerd store. + worker: + image: ruby/rails7-delayed-job + command: /commands/run-worker + depends_on: + - postgres + env_file: + - ../../appsignal.env + - ../../appsignal_key.env + volumes: + - ./app:/app + - ../integration:/integration tests: build: ../../support/server-tests image: server-tests diff --git a/ruby/rails7-goodjob/app/processmon.toml b/ruby/rails7-goodjob/app/processmon.toml index 1ee66b533..0b1962e22 100644 --- a/ruby/rails7-goodjob/app/processmon.toml +++ b/ruby/rails7-goodjob/app/processmon.toml @@ -10,15 +10,6 @@ ignore = [ [[paths_to_watch]] path = "/integration" -[processes.good_job] -command = "bundle" -args = [ - "exec", - "good_job", - "start" -] -working_dir = "/app" - [processes.rails] command = "bin/rails" args = [ diff --git a/ruby/rails7-goodjob/app/processmon.worker.toml b/ruby/rails7-goodjob/app/processmon.worker.toml new file mode 100644 index 000000000..c5b59b84c --- /dev/null +++ b/ruby/rails7-goodjob/app/processmon.worker.toml @@ -0,0 +1,24 @@ +[[paths_to_watch]] +path = "/app" +ignore = [ + "tmp", + "log", + "node_modules", + "public/packs" +] + +[[paths_to_watch]] +path = "/integration" + +# The worker service runs only the background job processor. Jobs enqueued by +# the web `app` service are performed here, so the job's trace runs under the +# worker's own app name (agent mode) or `worker` service name (collector mode), +# linked back to the enqueue span in `app`. +[processes.good_job] +command = "bundle" +args = [ + "exec", + "good_job", + "start" +] +working_dir = "/app" diff --git a/ruby/rails7-goodjob/commands/run-worker b/ruby/rails7-goodjob/commands/run-worker new file mode 100755 index 000000000..1bd5ac710 --- /dev/null +++ b/ruby/rails7-goodjob/commands/run-worker @@ -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 worker processes" +/commands/processmon processmon.worker.toml diff --git a/ruby/rails7-goodjob/docker-compose.agent.yml b/ruby/rails7-goodjob/docker-compose.agent.yml index 1cbf79f1e..7cbfebae4 100644 --- a/ruby/rails7-goodjob/docker-compose.agent.yml +++ b/ruby/rails7-goodjob/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails7-goodjob + worker: + environment: + - APPSIGNAL_APP_NAME=ruby-rails7-goodjob-worker diff --git a/ruby/rails7-goodjob/docker-compose.collector.yml b/ruby/rails7-goodjob/docker-compose.collector.yml index ba3e4fdee..631c2ff67 100644 --- a/ruby/rails7-goodjob/docker-compose.collector.yml +++ b/ruby/rails7-goodjob/docker-compose.collector.yml @@ -8,3 +8,8 @@ services: - APPSIGNAL_APP_NAME=ruby-rails7-goodjob-collector - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=ruby-rails7-goodjob-collector + - APPSIGNAL_SERVICE_NAME=worker + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-goodjob/docker-compose.shared.yml b/ruby/rails7-goodjob/docker-compose.shared.yml index 6138d2eab..9ef3b5d3e 100644 --- a/ruby/rails7-goodjob/docker-compose.shared.yml +++ b/ruby/rails7-goodjob/docker-compose.shared.yml @@ -24,6 +24,21 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (see run-worker / processmon.worker.toml). + # Image-only (no build:) so `docker compose build` does not collide with the + # app service exporting the same image tag on Docker's containerd store. + worker: + image: ruby/rails7-goodjob + command: /commands/run-worker + 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 diff --git a/ruby/rails7-sidekiq/app/processmon.toml b/ruby/rails7-sidekiq/app/processmon.toml index 286194df4..0b1962e22 100644 --- a/ruby/rails7-sidekiq/app/processmon.toml +++ b/ruby/rails7-sidekiq/app/processmon.toml @@ -10,15 +10,6 @@ ignore = [ [[paths_to_watch]] path = "/integration" -[processes.sidekiq] -command = "bundle" -args = [ - "exec", - "sidekiq", - "--queue=default" -] -working_dir = "/app" - [processes.rails] command = "bin/rails" args = [ diff --git a/ruby/rails7-sidekiq/app/processmon.worker.toml b/ruby/rails7-sidekiq/app/processmon.worker.toml new file mode 100644 index 000000000..5aba97d63 --- /dev/null +++ b/ruby/rails7-sidekiq/app/processmon.worker.toml @@ -0,0 +1,24 @@ +[[paths_to_watch]] +path = "/app" +ignore = [ + "tmp", + "log", + "node_modules", + "public/packs" +] + +[[paths_to_watch]] +path = "/integration" + +# The worker service runs only the background job processor. Jobs enqueued by +# the web `app` service are performed here, so the job's trace runs under the +# worker's own app name (agent mode) or `worker` service name (collector mode), +# linked back to the enqueue span in `app`. +[processes.sidekiq] +command = "bundle" +args = [ + "exec", + "sidekiq", + "--queue=default" +] +working_dir = "/app" diff --git a/ruby/rails7-sidekiq/commands/run-worker b/ruby/rails7-sidekiq/commands/run-worker new file mode 100755 index 000000000..1bd5ac710 --- /dev/null +++ b/ruby/rails7-sidekiq/commands/run-worker @@ -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 worker processes" +/commands/processmon processmon.worker.toml diff --git a/ruby/rails7-sidekiq/docker-compose.agent.yml b/ruby/rails7-sidekiq/docker-compose.agent.yml index 6738c3ec3..526b1e480 100644 --- a/ruby/rails7-sidekiq/docker-compose.agent.yml +++ b/ruby/rails7-sidekiq/docker-compose.agent.yml @@ -8,3 +8,6 @@ services: downstream: environment: - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-downstream + worker: + environment: + - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-worker diff --git a/ruby/rails7-sidekiq/docker-compose.collector.yml b/ruby/rails7-sidekiq/docker-compose.collector.yml index 3df4f1513..e2a3c1d76 100644 --- a/ruby/rails7-sidekiq/docker-compose.collector.yml +++ b/ruby/rails7-sidekiq/docker-compose.collector.yml @@ -13,3 +13,8 @@ services: - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq-collector + - APPSIGNAL_SERVICE_NAME=worker + - 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 index e9ede61cb..c69a21455 100644 --- a/ruby/rails7-sidekiq/docker-compose.shared.yml +++ b/ruby/rails7-sidekiq/docker-compose.shared.yml @@ -37,7 +37,6 @@ services: # 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: @@ -53,6 +52,32 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (see run-worker / processmon.worker.toml). + # Image-only (no build:) so `docker compose build` does not collide with the + # app service exporting the same image tag on Docker's containerd store. + worker: + image: ruby/rails7-sidekiq + command: /commands/run-worker + depends_on: + - postgres + - redis + environment: + # 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. tests: build: ../../support/server-tests image: server-tests diff --git a/ruby/rails7-solid-queue/app/processmon.toml b/ruby/rails7-solid-queue/app/processmon.toml index af9061aac..44ace7c94 100644 --- a/ruby/rails7-solid-queue/app/processmon.toml +++ b/ruby/rails7-solid-queue/app/processmon.toml @@ -10,13 +10,6 @@ ignore = [ [[paths_to_watch]] path = "/integration" -[processes.solid_queue] -command = "bin/rake" -args = [ - "solid_queue:start" -] -working_dir = "/app" - [processes.rails] command = "bin/rails" args = [ diff --git a/ruby/rails7-solid-queue/app/processmon.worker.toml b/ruby/rails7-solid-queue/app/processmon.worker.toml new file mode 100644 index 000000000..32e697882 --- /dev/null +++ b/ruby/rails7-solid-queue/app/processmon.worker.toml @@ -0,0 +1,22 @@ +[[paths_to_watch]] +path = "/app" +ignore = [ + "tmp", + "log", + "node_modules", + "public/packs" +] + +[[paths_to_watch]] +path = "/integration" + +# The worker service runs only the background job processor. Jobs enqueued by +# the web `app` service are performed here, so the job's trace runs under the +# worker's own app name (agent mode) or `worker` service name (collector mode), +# linked back to the enqueue span in `app`. +[processes.solid_queue] +command = "bin/rake" +args = [ + "solid_queue:start" +] +working_dir = "/app" diff --git a/ruby/rails7-solid-queue/commands/run-worker b/ruby/rails7-solid-queue/commands/run-worker new file mode 100755 index 000000000..1bd5ac710 --- /dev/null +++ b/ruby/rails7-solid-queue/commands/run-worker @@ -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 worker processes" +/commands/processmon processmon.worker.toml diff --git a/ruby/rails7-solid-queue/docker-compose.agent.yml b/ruby/rails7-solid-queue/docker-compose.agent.yml index 56369bcbe..0f4e1d5a9 100644 --- a/ruby/rails7-solid-queue/docker-compose.agent.yml +++ b/ruby/rails7-solid-queue/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby-rails7-solid-queue + worker: + environment: + - APPSIGNAL_APP_NAME=ruby-rails7-solid-queue-worker diff --git a/ruby/rails7-solid-queue/docker-compose.collector.yml b/ruby/rails7-solid-queue/docker-compose.collector.yml index 0f616870d..229c964f7 100644 --- a/ruby/rails7-solid-queue/docker-compose.collector.yml +++ b/ruby/rails7-solid-queue/docker-compose.collector.yml @@ -8,3 +8,8 @@ services: - APPSIGNAL_APP_NAME=ruby-rails7-solid-queue-collector - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=ruby-rails7-solid-queue-collector + - APPSIGNAL_SERVICE_NAME=worker + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails7-solid-queue/docker-compose.shared.yml b/ruby/rails7-solid-queue/docker-compose.shared.yml index e8a2e6cbb..6229c2305 100644 --- a/ruby/rails7-solid-queue/docker-compose.shared.yml +++ b/ruby/rails7-solid-queue/docker-compose.shared.yml @@ -23,6 +23,20 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (see run-worker / processmon.worker.toml). + # Image-only (no build:) so `docker compose build` does not collide with the + # app service exporting the same image tag on Docker's containerd store. + worker: + image: ruby/rails7-solid-queue + command: /commands/run-worker + depends_on: + - postgres + env_file: + - ../../appsignal.env + - ../../appsignal_key.env + volumes: + - ./app:/app + - ../integration:/integration tests: build: ../../support/server-tests image: server-tests diff --git a/ruby/rails8-delayed-job/app/processmon.toml b/ruby/rails8-delayed-job/app/processmon.toml index 7e68afcfb..95736facf 100644 --- a/ruby/rails8-delayed-job/app/processmon.toml +++ b/ruby/rails8-delayed-job/app/processmon.toml @@ -11,13 +11,6 @@ ignore = [ [[paths_to_watch]] path = "/integration" -[processes.sidekiq] -command = "bin/rake" -args = [ - "jobs:work" -] -working_dir = "/app" - [processes.rails] command = "bin/rails" args = [ diff --git a/ruby/rails8-delayed-job/app/processmon.worker.toml b/ruby/rails8-delayed-job/app/processmon.worker.toml new file mode 100644 index 000000000..a78079363 --- /dev/null +++ b/ruby/rails8-delayed-job/app/processmon.worker.toml @@ -0,0 +1,23 @@ +[[paths_to_watch]] +path = "/app" +ignore = [ + "storage", + "tmp", + "log", + "node_modules", + "public/packs" +] + +[[paths_to_watch]] +path = "/integration" + +# The worker service runs only the background job processor. Jobs enqueued by +# the web `app` service are performed here, so the job's trace runs under the +# worker's own app name (agent mode) or `worker` service name (collector mode), +# linked back to the enqueue span in `app`. +[processes.sidekiq] +command = "bin/rake" +args = [ + "jobs:work" +] +working_dir = "/app" diff --git a/ruby/rails8-delayed-job/commands/run-worker b/ruby/rails8-delayed-job/commands/run-worker new file mode 100755 index 000000000..1bd5ac710 --- /dev/null +++ b/ruby/rails8-delayed-job/commands/run-worker @@ -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 worker processes" +/commands/processmon processmon.worker.toml diff --git a/ruby/rails8-delayed-job/docker-compose.agent.yml b/ruby/rails8-delayed-job/docker-compose.agent.yml index 06e45431b..4e231513d 100644 --- a/ruby/rails8-delayed-job/docker-compose.agent.yml +++ b/ruby/rails8-delayed-job/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/rails8-delayed-job + worker: + environment: + - APPSIGNAL_APP_NAME=ruby/rails8-delayed-job-worker diff --git a/ruby/rails8-delayed-job/docker-compose.collector.yml b/ruby/rails8-delayed-job/docker-compose.collector.yml index 0c2b7c9f1..a6fa459b5 100644 --- a/ruby/rails8-delayed-job/docker-compose.collector.yml +++ b/ruby/rails8-delayed-job/docker-compose.collector.yml @@ -8,3 +8,8 @@ services: - APPSIGNAL_APP_NAME=ruby/rails8-delayed-job-collector - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=ruby/rails8-delayed-job-collector + - APPSIGNAL_SERVICE_NAME=worker + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails8-delayed-job/docker-compose.shared.yml b/ruby/rails8-delayed-job/docker-compose.shared.yml index 0344c837e..028baf64f 100644 --- a/ruby/rails8-delayed-job/docker-compose.shared.yml +++ b/ruby/rails8-delayed-job/docker-compose.shared.yml @@ -21,6 +21,21 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (see run-worker / processmon.worker.toml). + # Image-only (no build:) so `docker compose build` does not collide with the + # app service exporting the same image tag on Docker's containerd store. + worker: + image: ruby/rails8-delayed-job + command: /commands/run-worker + 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 diff --git a/ruby/rails8-que/app/processmon.toml b/ruby/rails8-que/app/processmon.toml index 6f5d6ed8b..0b1962e22 100644 --- a/ruby/rails8-que/app/processmon.toml +++ b/ruby/rails8-que/app/processmon.toml @@ -10,15 +10,6 @@ ignore = [ [[paths_to_watch]] path = "/integration" -[processes.que] -command = "bundle" -args = [ - "exec", - "que", - "./config/environment.rb" -] -working_dir = "/app" - [processes.rails] command = "bin/rails" args = [ diff --git a/ruby/rails8-que/app/processmon.worker.toml b/ruby/rails8-que/app/processmon.worker.toml new file mode 100644 index 000000000..c7b1f6ed0 --- /dev/null +++ b/ruby/rails8-que/app/processmon.worker.toml @@ -0,0 +1,24 @@ +[[paths_to_watch]] +path = "/app" +ignore = [ + "tmp", + "log", + "node_modules", + "public/packs" +] + +[[paths_to_watch]] +path = "/integration" + +# The worker service runs only the background job processor. Jobs enqueued by +# the web `app` service are performed here, so the job's trace runs under the +# worker's own app name (agent mode) or `worker` service name (collector mode), +# linked back to the enqueue span in `app`. +[processes.que] +command = "bundle" +args = [ + "exec", + "que", + "./config/environment.rb" +] +working_dir = "/app" diff --git a/ruby/rails8-que/commands/run-worker b/ruby/rails8-que/commands/run-worker new file mode 100755 index 000000000..1bd5ac710 --- /dev/null +++ b/ruby/rails8-que/commands/run-worker @@ -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 worker processes" +/commands/processmon processmon.worker.toml diff --git a/ruby/rails8-que/docker-compose.agent.yml b/ruby/rails8-que/docker-compose.agent.yml index c3e07f4d4..58a903298 100644 --- a/ruby/rails8-que/docker-compose.agent.yml +++ b/ruby/rails8-que/docker-compose.agent.yml @@ -8,3 +8,6 @@ services: downstream: environment: - APPSIGNAL_APP_NAME=ruby/rails8-que-downstream + worker: + environment: + - APPSIGNAL_APP_NAME=ruby/rails8-que-worker diff --git a/ruby/rails8-que/docker-compose.collector.yml b/ruby/rails8-que/docker-compose.collector.yml index 4396cc76f..41f07eab6 100644 --- a/ruby/rails8-que/docker-compose.collector.yml +++ b/ruby/rails8-que/docker-compose.collector.yml @@ -13,3 +13,8 @@ services: - APPSIGNAL_APP_NAME=ruby/rails8-que-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=ruby/rails8-que-collector + - APPSIGNAL_SERVICE_NAME=worker + - 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 8bc1d316d..0de128996 100644 --- a/ruby/rails8-que/docker-compose.shared.yml +++ b/ruby/rails8-que/docker-compose.shared.yml @@ -28,7 +28,6 @@ services: # 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: @@ -40,6 +39,24 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (see run-worker / processmon.worker.toml). + # Image-only (no build:) so `docker compose build` does not collide with the + # app service exporting the same image tag on Docker's containerd store. + worker: + image: ruby/rails8-que + command: /commands/run-worker + depends_on: + - postgres + env_file: + - postgres.env + - ../../appsignal.env + - ../../appsignal_key.env + 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). tests: build: ../../support/server-tests image: server-tests diff --git a/ruby/rails8-resque/app/processmon.toml b/ruby/rails8-resque/app/processmon.toml index feebc4b54..0b1962e22 100644 --- a/ruby/rails8-resque/app/processmon.toml +++ b/ruby/rails8-resque/app/processmon.toml @@ -10,15 +10,6 @@ ignore = [ [[paths_to_watch]] path = "/integration" -[processes.resque] -command = "bundle" -args = [ - "exec", - "rake", - "resque:work" -] -working_dir = "/app" - [processes.rails] command = "bin/rails" args = [ diff --git a/ruby/rails8-resque/app/processmon.worker.toml b/ruby/rails8-resque/app/processmon.worker.toml new file mode 100644 index 000000000..eb661625a --- /dev/null +++ b/ruby/rails8-resque/app/processmon.worker.toml @@ -0,0 +1,24 @@ +[[paths_to_watch]] +path = "/app" +ignore = [ + "tmp", + "log", + "node_modules", + "public/packs" +] + +[[paths_to_watch]] +path = "/integration" + +# The worker service runs only the background job processor. Jobs enqueued by +# the web `app` service are performed here, so the job's trace runs under the +# worker's own app name (agent mode) or `worker` service name (collector mode), +# linked back to the enqueue span in `app`. +[processes.resque] +command = "bundle" +args = [ + "exec", + "rake", + "resque:work" +] +working_dir = "/app" diff --git a/ruby/rails8-resque/commands/run-worker b/ruby/rails8-resque/commands/run-worker new file mode 100755 index 000000000..1bd5ac710 --- /dev/null +++ b/ruby/rails8-resque/commands/run-worker @@ -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 worker processes" +/commands/processmon processmon.worker.toml diff --git a/ruby/rails8-resque/docker-compose.agent.yml b/ruby/rails8-resque/docker-compose.agent.yml index 50c3c7760..c57ed84a1 100644 --- a/ruby/rails8-resque/docker-compose.agent.yml +++ b/ruby/rails8-resque/docker-compose.agent.yml @@ -8,3 +8,6 @@ services: downstream: environment: - APPSIGNAL_APP_NAME=ruby/rails8-resque-downstream + worker: + environment: + - APPSIGNAL_APP_NAME=ruby/rails8-resque-worker diff --git a/ruby/rails8-resque/docker-compose.collector.yml b/ruby/rails8-resque/docker-compose.collector.yml index c8304f19d..ab3392ada 100644 --- a/ruby/rails8-resque/docker-compose.collector.yml +++ b/ruby/rails8-resque/docker-compose.collector.yml @@ -13,3 +13,8 @@ services: - APPSIGNAL_APP_NAME=ruby/rails8-resque-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=ruby/rails8-resque-collector + - APPSIGNAL_SERVICE_NAME=worker + - 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 da5a9d59b..e8a6e7dee 100644 --- a/ruby/rails8-resque/docker-compose.shared.yml +++ b/ruby/rails8-resque/docker-compose.shared.yml @@ -26,7 +26,6 @@ services: # 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: @@ -38,6 +37,24 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (see run-worker / processmon.worker.toml). + # Image-only (no build:) so `docker compose build` does not collide with the + # app service exporting the same image tag on Docker's containerd store. + worker: + image: ruby/rails8-resque + command: /commands/run-worker + depends_on: + - redis + env_file: + - redis.env + - ../../appsignal.env + - ../../appsignal_key.env + 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). tests: build: ../../support/server-tests image: server-tests diff --git a/ruby/rails8-sidekiq/app/processmon.toml b/ruby/rails8-sidekiq/app/processmon.toml index 286194df4..0b1962e22 100644 --- a/ruby/rails8-sidekiq/app/processmon.toml +++ b/ruby/rails8-sidekiq/app/processmon.toml @@ -10,15 +10,6 @@ ignore = [ [[paths_to_watch]] path = "/integration" -[processes.sidekiq] -command = "bundle" -args = [ - "exec", - "sidekiq", - "--queue=default" -] -working_dir = "/app" - [processes.rails] command = "bin/rails" args = [ diff --git a/ruby/rails8-sidekiq/app/processmon.worker.toml b/ruby/rails8-sidekiq/app/processmon.worker.toml new file mode 100644 index 000000000..9db6a1d62 --- /dev/null +++ b/ruby/rails8-sidekiq/app/processmon.worker.toml @@ -0,0 +1,24 @@ +[[paths_to_watch]] +path = "/app" +ignore = [ + "tmp", + "log", + "node_modules", + "public/packs" +] + +[[paths_to_watch]] +path = "/integration" + +# The worker service drains the default queue. Jobs enqueued by the web `app` +# service are performed here, so the job's trace runs under the worker's own +# app name (agent mode) or `worker` service name (collector mode), linked back +# to the enqueue span in `app`. +[processes.sidekiq] +command = "bundle" +args = [ + "exec", + "sidekiq", + "--queue=default" +] +working_dir = "/app" diff --git a/ruby/rails8-sidekiq/commands/run-worker b/ruby/rails8-sidekiq/commands/run-worker new file mode 100755 index 000000000..1bd5ac710 --- /dev/null +++ b/ruby/rails8-sidekiq/commands/run-worker @@ -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 worker processes" +/commands/processmon processmon.worker.toml diff --git a/ruby/rails8-sidekiq/docker-compose.agent.yml b/ruby/rails8-sidekiq/docker-compose.agent.yml index 96d3bc0a6..ad85c2c9e 100644 --- a/ruby/rails8-sidekiq/docker-compose.agent.yml +++ b/ruby/rails8-sidekiq/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=ruby/rails8-sidekiq + worker: + environment: + - APPSIGNAL_APP_NAME=ruby/rails8-sidekiq-worker diff --git a/ruby/rails8-sidekiq/docker-compose.collector.yml b/ruby/rails8-sidekiq/docker-compose.collector.yml index 0abf6069c..60cb56c96 100644 --- a/ruby/rails8-sidekiq/docker-compose.collector.yml +++ b/ruby/rails8-sidekiq/docker-compose.collector.yml @@ -8,3 +8,8 @@ services: - APPSIGNAL_APP_NAME=ruby/rails8-sidekiq-collector - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=ruby/rails8-sidekiq-collector + - APPSIGNAL_SERVICE_NAME=worker + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/ruby/rails8-sidekiq/docker-compose.shared.yml b/ruby/rails8-sidekiq/docker-compose.shared.yml index bb1921af0..c92bc3970 100644 --- a/ruby/rails8-sidekiq/docker-compose.shared.yml +++ b/ruby/rails8-sidekiq/docker-compose.shared.yml @@ -28,6 +28,28 @@ services: volumes: - ./app:/app - ../integration:/integration + # A second container of the same image that runs only the background worker, + # draining the queue the web `app` service enqueues to. Splitting the worker + # into its own service attributes job traces to a separate app (agent mode) + # or service (collector mode) from the web requests. + # No `build:` here on purpose. The `app` service builds this shared image; + # if a second service also built the same tag, `docker compose build` fails + # on Docker's containerd image store ("image already exists"). `rake app:up` + # builds `app` first, so the image exists before `worker` starts. + worker: + image: ruby/rails8-sidekiq + command: /commands/run-worker + depends_on: + - postgres + - redis + env_file: + - postgres.env + - redis.env + - ../../appsignal.env + - ../../appsignal_key.env + volumes: + - ./app:/app + - ../integration:/integration tests: build: ../../support/server-tests image: server-tests From addd03a0055929764656b76726ff6ae299d7f5bb Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 13 Jul 2026 15:37:21 +0200 Subject: [PATCH 09/12] Wait for migrations before starting the worker A DB-backed queue worker (que, solid_queue, delayed_job, good_job) polls a table that a migration creates. run-worker only waited for gems, so the worker could start before the app finished migrating, crash on the missing table, and stay dead, because processmon restarts a process on file changes but not when it exits. Wait for the app to serve HTTP before starting the worker. The app only starts its web server after it migrates, so a reachable app means the schema is ready. Redis-backed workers (sidekiq, resque) did not hit this, but the wait is harmless and keeps every run-worker identical. --- ruby/rails6-shoryuken/commands/run-worker | 16 +++++++++++++--- ruby/rails7-delayed-job/commands/run-worker | 16 +++++++++++++--- ruby/rails7-goodjob/commands/run-worker | 16 +++++++++++++--- ruby/rails7-sidekiq/commands/run-worker | 16 +++++++++++++--- ruby/rails7-solid-queue/commands/run-worker | 16 +++++++++++++--- ruby/rails8-delayed-job/commands/run-worker | 16 +++++++++++++--- ruby/rails8-que/commands/run-worker | 16 +++++++++++++--- ruby/rails8-resque/commands/run-worker | 16 +++++++++++++--- ruby/rails8-sidekiq/commands/run-worker | 16 +++++++++++++--- 9 files changed, 117 insertions(+), 27 deletions(-) diff --git a/ruby/rails6-shoryuken/commands/run-worker b/ruby/rails6-shoryuken/commands/run-worker index 1bd5ac710..e1aa38eca 100755 --- a/ruby/rails6-shoryuken/commands/run-worker +++ b/ruby/rails6-shoryuken/commands/run-worker @@ -5,14 +5,24 @@ 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. +# /app and /integration volumes. Wait for both before starting the worker. +# +# Gems: `bundle check` is read-only, so polling it is safe. 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 +# Migrations: a DB-backed queue (que, solid_queue, delayed_job, good_job) polls +# a table created by a migration. If the worker starts before the app migrates, +# the process crashes and processmon does not restart it. The app only serves +# HTTP after it has migrated, so a reachable app means migrations are done. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting worker processes" /commands/processmon processmon.worker.toml diff --git a/ruby/rails7-delayed-job/commands/run-worker b/ruby/rails7-delayed-job/commands/run-worker index 1bd5ac710..e1aa38eca 100755 --- a/ruby/rails7-delayed-job/commands/run-worker +++ b/ruby/rails7-delayed-job/commands/run-worker @@ -5,14 +5,24 @@ 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. +# /app and /integration volumes. Wait for both before starting the worker. +# +# Gems: `bundle check` is read-only, so polling it is safe. 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 +# Migrations: a DB-backed queue (que, solid_queue, delayed_job, good_job) polls +# a table created by a migration. If the worker starts before the app migrates, +# the process crashes and processmon does not restart it. The app only serves +# HTTP after it has migrated, so a reachable app means migrations are done. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting worker processes" /commands/processmon processmon.worker.toml diff --git a/ruby/rails7-goodjob/commands/run-worker b/ruby/rails7-goodjob/commands/run-worker index 1bd5ac710..e1aa38eca 100755 --- a/ruby/rails7-goodjob/commands/run-worker +++ b/ruby/rails7-goodjob/commands/run-worker @@ -5,14 +5,24 @@ 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. +# /app and /integration volumes. Wait for both before starting the worker. +# +# Gems: `bundle check` is read-only, so polling it is safe. 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 +# Migrations: a DB-backed queue (que, solid_queue, delayed_job, good_job) polls +# a table created by a migration. If the worker starts before the app migrates, +# the process crashes and processmon does not restart it. The app only serves +# HTTP after it has migrated, so a reachable app means migrations are done. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting worker processes" /commands/processmon processmon.worker.toml diff --git a/ruby/rails7-sidekiq/commands/run-worker b/ruby/rails7-sidekiq/commands/run-worker index 1bd5ac710..e1aa38eca 100755 --- a/ruby/rails7-sidekiq/commands/run-worker +++ b/ruby/rails7-sidekiq/commands/run-worker @@ -5,14 +5,24 @@ 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. +# /app and /integration volumes. Wait for both before starting the worker. +# +# Gems: `bundle check` is read-only, so polling it is safe. 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 +# Migrations: a DB-backed queue (que, solid_queue, delayed_job, good_job) polls +# a table created by a migration. If the worker starts before the app migrates, +# the process crashes and processmon does not restart it. The app only serves +# HTTP after it has migrated, so a reachable app means migrations are done. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting worker processes" /commands/processmon processmon.worker.toml diff --git a/ruby/rails7-solid-queue/commands/run-worker b/ruby/rails7-solid-queue/commands/run-worker index 1bd5ac710..e1aa38eca 100755 --- a/ruby/rails7-solid-queue/commands/run-worker +++ b/ruby/rails7-solid-queue/commands/run-worker @@ -5,14 +5,24 @@ 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. +# /app and /integration volumes. Wait for both before starting the worker. +# +# Gems: `bundle check` is read-only, so polling it is safe. 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 +# Migrations: a DB-backed queue (que, solid_queue, delayed_job, good_job) polls +# a table created by a migration. If the worker starts before the app migrates, +# the process crashes and processmon does not restart it. The app only serves +# HTTP after it has migrated, so a reachable app means migrations are done. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting worker processes" /commands/processmon processmon.worker.toml diff --git a/ruby/rails8-delayed-job/commands/run-worker b/ruby/rails8-delayed-job/commands/run-worker index 1bd5ac710..e1aa38eca 100755 --- a/ruby/rails8-delayed-job/commands/run-worker +++ b/ruby/rails8-delayed-job/commands/run-worker @@ -5,14 +5,24 @@ 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. +# /app and /integration volumes. Wait for both before starting the worker. +# +# Gems: `bundle check` is read-only, so polling it is safe. 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 +# Migrations: a DB-backed queue (que, solid_queue, delayed_job, good_job) polls +# a table created by a migration. If the worker starts before the app migrates, +# the process crashes and processmon does not restart it. The app only serves +# HTTP after it has migrated, so a reachable app means migrations are done. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting worker processes" /commands/processmon processmon.worker.toml diff --git a/ruby/rails8-que/commands/run-worker b/ruby/rails8-que/commands/run-worker index 1bd5ac710..e1aa38eca 100755 --- a/ruby/rails8-que/commands/run-worker +++ b/ruby/rails8-que/commands/run-worker @@ -5,14 +5,24 @@ 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. +# /app and /integration volumes. Wait for both before starting the worker. +# +# Gems: `bundle check` is read-only, so polling it is safe. 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 +# Migrations: a DB-backed queue (que, solid_queue, delayed_job, good_job) polls +# a table created by a migration. If the worker starts before the app migrates, +# the process crashes and processmon does not restart it. The app only serves +# HTTP after it has migrated, so a reachable app means migrations are done. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting worker processes" /commands/processmon processmon.worker.toml diff --git a/ruby/rails8-resque/commands/run-worker b/ruby/rails8-resque/commands/run-worker index 1bd5ac710..e1aa38eca 100755 --- a/ruby/rails8-resque/commands/run-worker +++ b/ruby/rails8-resque/commands/run-worker @@ -5,14 +5,24 @@ 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. +# /app and /integration volumes. Wait for both before starting the worker. +# +# Gems: `bundle check` is read-only, so polling it is safe. 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 +# Migrations: a DB-backed queue (que, solid_queue, delayed_job, good_job) polls +# a table created by a migration. If the worker starts before the app migrates, +# the process crashes and processmon does not restart it. The app only serves +# HTTP after it has migrated, so a reachable app means migrations are done. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting worker processes" /commands/processmon processmon.worker.toml diff --git a/ruby/rails8-sidekiq/commands/run-worker b/ruby/rails8-sidekiq/commands/run-worker index 1bd5ac710..e1aa38eca 100755 --- a/ruby/rails8-sidekiq/commands/run-worker +++ b/ruby/rails8-sidekiq/commands/run-worker @@ -5,14 +5,24 @@ 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. +# /app and /integration volumes. Wait for both before starting the worker. +# +# Gems: `bundle check` is read-only, so polling it is safe. 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 +# Migrations: a DB-backed queue (que, solid_queue, delayed_job, good_job) polls +# a table created by a migration. If the worker starts before the app migrates, +# the process crashes and processmon does not restart it. The app only serves +# HTTP after it has migrated, so a reachable app means migrations are done. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting worker processes" /commands/processmon processmon.worker.toml From d812957b5bba6103da87665fbe22510008961aa7 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 13 Jul 2026 16:02:50 +0200 Subject: [PATCH 10/12] Add a downstream HTTP service to the Django apps The Django apps' make_request view called an external, uninstrumented URL (appsignal.com), so nothing continued the trace. Point it at a second, separately-instrumented instance of the same app (the `downstream` service) via DOWNSTREAM_URL, so the trace spans both. Falls back to the external URL when unset, so single-service runs still work. In agent mode the downstream is a separate `-downstream` app. In collector mode it shares the app name and sets APPSIGNAL_SERVICE_NAME=downstream, so it is a distinct service. Python deps install per container, not into a shared volume. The downstream container waits until the app serves HTTP before installing. That one signal means the app has built the appsignal dist into the shared /integration volume (so we install from it instead of racing its `hatch build`) and has migrated (so we never migrate from here). `downstream` is added to ALLOWED_HOSTS, since Django rejects the `Host: downstream:4002` header otherwise. The service is image-only so `docker compose build` does not collide on the shared image tag. Only the Django apps are changed, because they are the only Python setups that make an outbound HTTP request. --- .../settings.py | 2 +- .../appsignal_python_opentelemetry/views.py | 3 ++- .../app/processmon.downstream.toml | 14 +++++++++++ python/django4-asgi/commands/run-downstream | 24 +++++++++++++++++++ python/django4-asgi/docker-compose.agent.yml | 3 +++ .../django4-asgi/docker-compose.collector.yml | 5 ++++ python/django4-asgi/docker-compose.shared.yml | 16 +++++++++++++ .../settings.py | 2 +- .../appsignal_python_opentelemetry/views.py | 9 +++++-- .../app/processmon.downstream.toml | 15 ++++++++++++ python/django4-celery/commands/run-downstream | 23 ++++++++++++++++++ .../django4-celery/docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ .../django4-celery/docker-compose.shared.yml | 21 ++++++++++++++++ .../settings.py | 2 +- .../appsignal_python_opentelemetry/views.py | 3 ++- .../app/processmon.downstream.toml | 12 ++++++++++ python/django4-wsgi/commands/run-downstream | 22 +++++++++++++++++ python/django4-wsgi/docker-compose.agent.yml | 3 +++ .../django4-wsgi/docker-compose.collector.yml | 5 ++++ python/django4-wsgi/docker-compose.shared.yml | 16 +++++++++++++ .../settings.py | 2 +- .../appsignal_python_opentelemetry/views.py | 3 ++- .../app/processmon.downstream.toml | 12 ++++++++++ python/django5-celery/commands/run-downstream | 22 +++++++++++++++++ .../django5-celery/docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ .../django5-celery/docker-compose.shared.yml | 19 +++++++++++++++ 28 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 python/django4-asgi/app/processmon.downstream.toml create mode 100755 python/django4-asgi/commands/run-downstream create mode 100644 python/django4-celery/app/processmon.downstream.toml create mode 100755 python/django4-celery/commands/run-downstream create mode 100644 python/django4-wsgi/app/processmon.downstream.toml create mode 100755 python/django4-wsgi/commands/run-downstream create mode 100644 python/django5-celery/app/processmon.downstream.toml create mode 100755 python/django5-celery/commands/run-downstream diff --git a/python/django4-asgi/app/appsignal_python_opentelemetry/settings.py b/python/django4-asgi/app/appsignal_python_opentelemetry/settings.py index 0842ad00f..193158e10 100644 --- a/python/django4-asgi/app/appsignal_python_opentelemetry/settings.py +++ b/python/django4-asgi/app/appsignal_python_opentelemetry/settings.py @@ -26,7 +26,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['app', 'localhost'] +ALLOWED_HOSTS = ['app', 'downstream', 'localhost'] # Application definition diff --git a/python/django4-asgi/app/appsignal_python_opentelemetry/views.py b/python/django4-asgi/app/appsignal_python_opentelemetry/views.py index 111501bb2..a8f3e19dc 100644 --- a/python/django4-asgi/app/appsignal_python_opentelemetry/views.py +++ b/python/django4-asgi/app/appsignal_python_opentelemetry/views.py @@ -1,3 +1,4 @@ +import os import requests import json import time @@ -84,7 +85,7 @@ def error_queue_inline(request): def make_request(request): - requests.get('https://www.appsignal.com/') + requests.get(os.environ.get("DOWNSTREAM_URL", "https://www.appsignal.com/")) return HttpResponse("I did a request to appsignal.com!") diff --git a/python/django4-asgi/app/processmon.downstream.toml b/python/django4-asgi/app/processmon.downstream.toml new file mode 100644 index 000000000..ef2b749f1 --- /dev/null +++ b/python/django4-asgi/app/processmon.downstream.toml @@ -0,0 +1,14 @@ +[[paths_to_watch]] +path = "/app" + +# Downstream web server on :4002 (no celery here; that is the worker split). +[processes.django] +command = "python3" +args = [ + "-m", + "uvicorn", + "appsignal_python_opentelemetry.asgi:application", + "--host", + "0.0.0.0" +] +working_dir = "/app" diff --git a/python/django4-asgi/commands/run-downstream b/python/django4-asgi/commands/run-downstream new file mode 100755 index 000000000..146d97134 --- /dev/null +++ b/python/django4-asgi/commands/run-downstream @@ -0,0 +1,24 @@ +#!/bin/bash + +set -eu + +cd /app + +# The `app` service builds the appsignal dist into the shared /integration +# volume and runs migrations, then serves HTTP. Wait for it to be reachable: +# that means the dist is built (so we install from it without racing the app's +# `hatch build`) and the schema is migrated (so we never migrate from here). +echo "Waiting for the app service to be ready..." +until python3 -c "import socket; socket.create_connection(('app', 4001), 2).close()" >/dev/null 2>&1; do + echo "App not ready yet, retrying..." + sleep 2 +done + +echo "Installing dependencies" +pip3 install -r requirements.txt +pip3 install /integration/dist/* --force-reinstall + +export UVICORN_PORT=4002 + +echo "Starting downstream processes" +/commands/processmon processmon.downstream.toml diff --git a/python/django4-asgi/docker-compose.agent.yml b/python/django4-asgi/docker-compose.agent.yml index 83d8ee52c..c6fe94d29 100644 --- a/python/django4-asgi/docker-compose.agent.yml +++ b/python/django4-asgi/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/django4-asgi + downstream: + environment: + - APPSIGNAL_APP_NAME=python/django4-asgi-downstream diff --git a/python/django4-asgi/docker-compose.collector.yml b/python/django4-asgi/docker-compose.collector.yml index d6e0346a5..9f145d0ce 100644 --- a/python/django4-asgi/docker-compose.collector.yml +++ b/python/django4-asgi/docker-compose.collector.yml @@ -8,3 +8,8 @@ services: - APPSIGNAL_APP_NAME=python/django4-asgi-collector - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + downstream: + environment: + - APPSIGNAL_APP_NAME=python/django4-asgi-collector + - APPSIGNAL_SERVICE_NAME=downstream + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django4-asgi/docker-compose.shared.yml b/python/django4-asgi/docker-compose.shared.yml index ce3bbb9ac..a0b681a89 100644 --- a/python/django4-asgi/docker-compose.shared.yml +++ b/python/django4-asgi/docker-compose.shared.yml @@ -28,6 +28,22 @@ services: - ../../appsignal_key.env environment: - PORT=4001 + - DOWNSTREAM_URL=http://downstream:4002/ + volumes: + - ./app:/app + - ../integration:/integration + # Second instance of the same image (image-only; only `app` builds). Receives + # the HTTP request the `app` service makes, so the trace continues into it. + downstream: + image: python/django4-asgi + command: /commands/run-downstream + depends_on: + - postgres + - redis + env_file: + - postgres.env + - ../../appsignal.env + - ../../appsignal_key.env volumes: - ./app:/app - ../integration:/integration diff --git a/python/django4-celery/app/appsignal_python_opentelemetry/settings.py b/python/django4-celery/app/appsignal_python_opentelemetry/settings.py index 0842ad00f..193158e10 100644 --- a/python/django4-celery/app/appsignal_python_opentelemetry/settings.py +++ b/python/django4-celery/app/appsignal_python_opentelemetry/settings.py @@ -26,7 +26,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['app', 'localhost'] +ALLOWED_HOSTS = ['app', 'downstream', 'localhost'] # Application definition diff --git a/python/django4-celery/app/appsignal_python_opentelemetry/views.py b/python/django4-celery/app/appsignal_python_opentelemetry/views.py index 81ff096ef..ef339e0ea 100644 --- a/python/django4-celery/app/appsignal_python_opentelemetry/views.py +++ b/python/django4-celery/app/appsignal_python_opentelemetry/views.py @@ -1,3 +1,4 @@ +import os import requests import json import time @@ -91,9 +92,13 @@ def error_queue_inline(request): return HttpResponse("I ran an error_task inline!") +# Calls a second, separately-instrumented app (the `downstream` service) so the +# injected traceparent is extracted there and the trace spans both apps. Falls +# back to the external, uninstrumented URL when DOWNSTREAM_URL is unset, so the +# setup still works as a single service. def make_request(request): - requests.get('https://www.appsignal.com/') - return HttpResponse("I did a request to appsignal.com!") + requests.get(os.environ.get("DOWNSTREAM_URL", "https://www.appsignal.com/")) + return HttpResponse("I did a request downstream!") def custom_instrumentation(request): diff --git a/python/django4-celery/app/processmon.downstream.toml b/python/django4-celery/app/processmon.downstream.toml new file mode 100644 index 000000000..8a463add9 --- /dev/null +++ b/python/django4-celery/app/processmon.downstream.toml @@ -0,0 +1,15 @@ +[[paths_to_watch]] +path = "/app" + +# The downstream service is a second instance of the same Django app on a +# different port. It receives the HTTP request the `app` service makes, so the +# trace continues into it. In collector mode it reports under the `downstream` +# service name; in agent mode under a separate `-downstream` app. +[processes.django] +command = "python3" +args = [ + "manage.py", + "runserver", + "0.0.0.0:4002" +] +working_dir = "/app" diff --git a/python/django4-celery/commands/run-downstream b/python/django4-celery/commands/run-downstream new file mode 100755 index 000000000..0583e8792 --- /dev/null +++ b/python/django4-celery/commands/run-downstream @@ -0,0 +1,23 @@ +#!/bin/bash + +set -eu + +cd /app + +# The `app` service owns building the appsignal dist into the shared +# /integration volume and running migrations. Wait for it to start serving +# before doing anything here. That single signal means the dist is built, so we +# can install from it without racing the app's `hatch build`, and it means the +# schema is migrated, so this container never runs migrations itself. +echo "Waiting for the app service to be ready..." +until python3 -c "import socket; socket.create_connection(('app', 4001), 2).close()" >/dev/null 2>&1; do + echo "App not ready yet, retrying..." + sleep 2 +done + +echo "Installing dependencies" +pip3 install -r requirements.txt +pip3 install /integration/dist/* --force-reinstall + +echo "Starting downstream processes" +/commands/processmon processmon.downstream.toml diff --git a/python/django4-celery/docker-compose.agent.yml b/python/django4-celery/docker-compose.agent.yml index 9ec6f3634..dccbdb43b 100644 --- a/python/django4-celery/docker-compose.agent.yml +++ b/python/django4-celery/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/django4-celery + downstream: + environment: + - APPSIGNAL_APP_NAME=python/django4-celery-downstream diff --git a/python/django4-celery/docker-compose.collector.yml b/python/django4-celery/docker-compose.collector.yml index 15e57d735..57798d140 100644 --- a/python/django4-celery/docker-compose.collector.yml +++ b/python/django4-celery/docker-compose.collector.yml @@ -8,3 +8,8 @@ services: - APPSIGNAL_APP_NAME=python/django4-celery-collector - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + downstream: + environment: + - APPSIGNAL_APP_NAME=python/django4-celery-collector + - APPSIGNAL_SERVICE_NAME=downstream + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django4-celery/docker-compose.shared.yml b/python/django4-celery/docker-compose.shared.yml index 271d75c38..d7c3e0755 100644 --- a/python/django4-celery/docker-compose.shared.yml +++ b/python/django4-celery/docker-compose.shared.yml @@ -28,6 +28,27 @@ services: - ../../appsignal_key.env environment: - PORT=4001 + # The make_request view calls this second, separately-instrumented app so + # the trace propagates across services. Falls back to an external URL when + # unset (single-service runs). + - DOWNSTREAM_URL=http://downstream:4002/ + volumes: + - ./app:/app + - ../integration:/integration + # A second instance of the same image, running as a distinct AppSignal app + # (agent mode) or service (collector mode). It receives the HTTP request the + # `app` service makes, so the trace continues into it. Image-only: only `app` + # builds, so `docker compose build` does not collide on the shared image tag. + downstream: + image: python/django4-celery + command: /commands/run-downstream + depends_on: + - postgres + - redis + env_file: + - postgres.env + - ../../appsignal.env + - ../../appsignal_key.env volumes: - ./app:/app - ../integration:/integration diff --git a/python/django4-wsgi/app/appsignal_python_opentelemetry/settings.py b/python/django4-wsgi/app/appsignal_python_opentelemetry/settings.py index 0842ad00f..193158e10 100644 --- a/python/django4-wsgi/app/appsignal_python_opentelemetry/settings.py +++ b/python/django4-wsgi/app/appsignal_python_opentelemetry/settings.py @@ -26,7 +26,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['app', 'localhost'] +ALLOWED_HOSTS = ['app', 'downstream', 'localhost'] # Application definition diff --git a/python/django4-wsgi/app/appsignal_python_opentelemetry/views.py b/python/django4-wsgi/app/appsignal_python_opentelemetry/views.py index 111501bb2..a8f3e19dc 100644 --- a/python/django4-wsgi/app/appsignal_python_opentelemetry/views.py +++ b/python/django4-wsgi/app/appsignal_python_opentelemetry/views.py @@ -1,3 +1,4 @@ +import os import requests import json import time @@ -84,7 +85,7 @@ def error_queue_inline(request): def make_request(request): - requests.get('https://www.appsignal.com/') + requests.get(os.environ.get("DOWNSTREAM_URL", "https://www.appsignal.com/")) return HttpResponse("I did a request to appsignal.com!") diff --git a/python/django4-wsgi/app/processmon.downstream.toml b/python/django4-wsgi/app/processmon.downstream.toml new file mode 100644 index 000000000..86e5be8f4 --- /dev/null +++ b/python/django4-wsgi/app/processmon.downstream.toml @@ -0,0 +1,12 @@ +[[paths_to_watch]] +path = "/app" + +# Downstream web server on :4002 (no celery here; that is the worker split). +[processes.django] +command = "gunicorn" +args = [ + "appsignal_python_opentelemetry.wsgi", + "--bind", + "0.0.0.0:4002" +] +working_dir = "/app" diff --git a/python/django4-wsgi/commands/run-downstream b/python/django4-wsgi/commands/run-downstream new file mode 100755 index 000000000..707620794 --- /dev/null +++ b/python/django4-wsgi/commands/run-downstream @@ -0,0 +1,22 @@ +#!/bin/bash + +set -eu + +cd /app + +# The `app` service builds the appsignal dist into the shared /integration +# volume and runs migrations, then serves HTTP. Wait for it to be reachable: +# that means the dist is built (so we install from it without racing the app's +# `hatch build`) and the schema is migrated (so we never migrate from here). +echo "Waiting for the app service to be ready..." +until python3 -c "import socket; socket.create_connection(('app', 4001), 2).close()" >/dev/null 2>&1; do + echo "App not ready yet, retrying..." + sleep 2 +done + +echo "Installing dependencies" +pip3 install -r requirements.txt +pip3 install /integration/dist/* --force-reinstall + +echo "Starting downstream processes" +/commands/processmon processmon.downstream.toml diff --git a/python/django4-wsgi/docker-compose.agent.yml b/python/django4-wsgi/docker-compose.agent.yml index 483db63ba..40193c2ce 100644 --- a/python/django4-wsgi/docker-compose.agent.yml +++ b/python/django4-wsgi/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/django4-wsgi + downstream: + environment: + - APPSIGNAL_APP_NAME=python/django4-wsgi-downstream diff --git a/python/django4-wsgi/docker-compose.collector.yml b/python/django4-wsgi/docker-compose.collector.yml index aabf1c1ce..3ca4fc163 100644 --- a/python/django4-wsgi/docker-compose.collector.yml +++ b/python/django4-wsgi/docker-compose.collector.yml @@ -8,3 +8,8 @@ services: - APPSIGNAL_APP_NAME=python/django4-wsgi-collector - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + downstream: + environment: + - APPSIGNAL_APP_NAME=python/django4-wsgi-collector + - APPSIGNAL_SERVICE_NAME=downstream + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django4-wsgi/docker-compose.shared.yml b/python/django4-wsgi/docker-compose.shared.yml index abae6b943..8628011f8 100644 --- a/python/django4-wsgi/docker-compose.shared.yml +++ b/python/django4-wsgi/docker-compose.shared.yml @@ -28,6 +28,22 @@ services: - ../../appsignal_key.env environment: - PORT=4001 + - DOWNSTREAM_URL=http://downstream:4002/ + volumes: + - ./app:/app + - ../integration:/integration + # Second instance of the same image (image-only; only `app` builds). Receives + # the HTTP request the `app` service makes, so the trace continues into it. + downstream: + image: python/django4-wsgi + command: /commands/run-downstream + depends_on: + - postgres + - redis + env_file: + - postgres.env + - ../../appsignal.env + - ../../appsignal_key.env volumes: - ./app:/app - ../integration:/integration diff --git a/python/django5-celery/app/appsignal_python_opentelemetry/settings.py b/python/django5-celery/app/appsignal_python_opentelemetry/settings.py index 0c7fbec62..78f131dab 100644 --- a/python/django5-celery/app/appsignal_python_opentelemetry/settings.py +++ b/python/django5-celery/app/appsignal_python_opentelemetry/settings.py @@ -27,7 +27,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['app', 'localhost'] +ALLOWED_HOSTS = ['app', 'downstream', 'localhost'] # Application definition diff --git a/python/django5-celery/app/appsignal_python_opentelemetry/views.py b/python/django5-celery/app/appsignal_python_opentelemetry/views.py index 204a74119..a42cafd2b 100644 --- a/python/django5-celery/app/appsignal_python_opentelemetry/views.py +++ b/python/django5-celery/app/appsignal_python_opentelemetry/views.py @@ -1,3 +1,4 @@ +import os import requests import json import time @@ -152,7 +153,7 @@ def error_queue_inline(request): def make_request(request): - requests.get('https://www.appsignal.com/') + requests.get(os.environ.get("DOWNSTREAM_URL", "https://www.appsignal.com/")) return HttpResponse("I did a request to appsignal.com!") diff --git a/python/django5-celery/app/processmon.downstream.toml b/python/django5-celery/app/processmon.downstream.toml new file mode 100644 index 000000000..8f5cc974c --- /dev/null +++ b/python/django5-celery/app/processmon.downstream.toml @@ -0,0 +1,12 @@ +[[paths_to_watch]] +path = "/app" + +# Downstream web server on :4002 (no celery here; that is the worker split). +[processes.django] +command = "python3" +args = [ + "manage.py", + "runserver", + "0.0.0.0:4002" +] +working_dir = "/app" diff --git a/python/django5-celery/commands/run-downstream b/python/django5-celery/commands/run-downstream new file mode 100755 index 000000000..707620794 --- /dev/null +++ b/python/django5-celery/commands/run-downstream @@ -0,0 +1,22 @@ +#!/bin/bash + +set -eu + +cd /app + +# The `app` service builds the appsignal dist into the shared /integration +# volume and runs migrations, then serves HTTP. Wait for it to be reachable: +# that means the dist is built (so we install from it without racing the app's +# `hatch build`) and the schema is migrated (so we never migrate from here). +echo "Waiting for the app service to be ready..." +until python3 -c "import socket; socket.create_connection(('app', 4001), 2).close()" >/dev/null 2>&1; do + echo "App not ready yet, retrying..." + sleep 2 +done + +echo "Installing dependencies" +pip3 install -r requirements.txt +pip3 install /integration/dist/* --force-reinstall + +echo "Starting downstream processes" +/commands/processmon processmon.downstream.toml diff --git a/python/django5-celery/docker-compose.agent.yml b/python/django5-celery/docker-compose.agent.yml index fbffc1731..8ea10a071 100644 --- a/python/django5-celery/docker-compose.agent.yml +++ b/python/django5-celery/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/django5-celery + downstream: + environment: + - APPSIGNAL_APP_NAME=python/django5-celery-downstream diff --git a/python/django5-celery/docker-compose.collector.yml b/python/django5-celery/docker-compose.collector.yml index 6a10728c1..196f9239a 100644 --- a/python/django5-celery/docker-compose.collector.yml +++ b/python/django5-celery/docker-compose.collector.yml @@ -8,3 +8,8 @@ services: - APPSIGNAL_APP_NAME=python/django5-celery-collector - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + downstream: + environment: + - APPSIGNAL_APP_NAME=python/django5-celery-collector + - APPSIGNAL_SERVICE_NAME=downstream + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django5-celery/docker-compose.shared.yml b/python/django5-celery/docker-compose.shared.yml index 71372be60..0717662e9 100644 --- a/python/django5-celery/docker-compose.shared.yml +++ b/python/django5-celery/docker-compose.shared.yml @@ -28,6 +28,25 @@ services: - ../../appsignal_key.env environment: - PORT=4001 + - DOWNSTREAM_URL=http://downstream:4002/ + - APPSIGNAL_APP_ENV=dev + - APPSIGNAL_LOG_LEVEL=trace + volumes: + - ./app:/app + - ../integration:/integration + # Second instance of the same image (image-only; only `app` builds). Receives + # the HTTP request the `app` service makes, so the trace continues into it. + downstream: + image: python/django5-celery + command: /commands/run-downstream + depends_on: + - postgres + - redis + env_file: + - postgres.env + - ../../appsignal.env + - ../../appsignal_key.env + environment: - APPSIGNAL_APP_ENV=dev - APPSIGNAL_LOG_LEVEL=trace volumes: From ab46cad09e2090eeabafb1697d314cbeaf1a1fa1 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 13 Jul 2026 16:14:17 +0200 Subject: [PATCH 11/12] Split the Python background worker into a service The Django apps ran their Celery worker, and flask-pika ran its RabbitMQ consumer, as a second process inside the `app` container next to the web server. Move it into a dedicated `worker` container that runs the same image, mirroring a real web/worker deployment. In agent mode the worker reports to a `-worker` app. In collector mode it shares the app name and sets APPSIGNAL_SERVICE_NAME=worker, so its work groups under a `worker` service. The worker container waits for the app to serve HTTP before installing, the same readiness signal the downstream service uses: it means the appsignal dist is built in the shared /integration volume and the schema is migrated. The service is image-only so `docker compose build` does not collide on the shared image tag. --- python/django4-asgi/app/processmon.toml | 10 -------- .../django4-asgi/app/processmon.worker.toml | 15 ++++++++++++ python/django4-asgi/commands/run-worker | 23 +++++++++++++++++++ python/django4-asgi/docker-compose.agent.yml | 3 +++ .../django4-asgi/docker-compose.collector.yml | 5 ++++ python/django4-asgi/docker-compose.shared.yml | 14 +++++++++++ python/django4-celery/app/processmon.toml | 10 -------- .../django4-celery/app/processmon.worker.toml | 15 ++++++++++++ python/django4-celery/commands/run-worker | 23 +++++++++++++++++++ .../django4-celery/docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ .../django4-celery/docker-compose.shared.yml | 14 +++++++++++ python/django4-wsgi/app/processmon.toml | 10 -------- .../django4-wsgi/app/processmon.worker.toml | 15 ++++++++++++ python/django4-wsgi/commands/run-worker | 23 +++++++++++++++++++ python/django4-wsgi/docker-compose.agent.yml | 3 +++ .../django4-wsgi/docker-compose.collector.yml | 5 ++++ python/django4-wsgi/docker-compose.shared.yml | 14 +++++++++++ python/django5-celery/app/processmon.toml | 10 -------- .../django5-celery/app/processmon.worker.toml | 15 ++++++++++++ python/django5-celery/commands/run-worker | 23 +++++++++++++++++++ .../django5-celery/docker-compose.agent.yml | 3 +++ .../docker-compose.collector.yml | 5 ++++ .../django5-celery/docker-compose.shared.yml | 17 ++++++++++++++ python/flask-pika/app/processmon.toml | 7 ------ python/flask-pika/app/processmon.worker.toml | 12 ++++++++++ python/flask-pika/commands/run-worker | 23 +++++++++++++++++++ python/flask-pika/docker-compose.agent.yml | 3 +++ .../flask-pika/docker-compose.collector.yml | 5 ++++ python/flask-pika/docker-compose.shared.yml | 12 ++++++++++ 30 files changed, 298 insertions(+), 47 deletions(-) create mode 100644 python/django4-asgi/app/processmon.worker.toml create mode 100755 python/django4-asgi/commands/run-worker create mode 100644 python/django4-celery/app/processmon.worker.toml create mode 100755 python/django4-celery/commands/run-worker create mode 100644 python/django4-wsgi/app/processmon.worker.toml create mode 100755 python/django4-wsgi/commands/run-worker create mode 100644 python/django5-celery/app/processmon.worker.toml create mode 100755 python/django5-celery/commands/run-worker create mode 100644 python/flask-pika/app/processmon.worker.toml create mode 100755 python/flask-pika/commands/run-worker diff --git a/python/django4-asgi/app/processmon.toml b/python/django4-asgi/app/processmon.toml index f60d586e9..e7d12dd83 100644 --- a/python/django4-asgi/app/processmon.toml +++ b/python/django4-asgi/app/processmon.toml @@ -11,13 +11,3 @@ args = [ "0.0.0.0" ] working_dir = "/app" - -[processes.celery] -command = "celery" -args = [ - "--app=tasks", - "worker", - "--loglevel=INFO", - "--queues=high-priority,low-priority" -] -working_dir = "/app" diff --git a/python/django4-asgi/app/processmon.worker.toml b/python/django4-asgi/app/processmon.worker.toml new file mode 100644 index 000000000..5a1ff7183 --- /dev/null +++ b/python/django4-asgi/app/processmon.worker.toml @@ -0,0 +1,15 @@ +[[paths_to_watch]] +path = "/app" + +# The worker service runs only the background worker. Work enqueued by the web +# `app` service is performed here, so it reports under the worker's own app name +# (agent mode) or `worker` service name (collector mode). +[processes.celery] +command = "celery" +args = [ + "--app=tasks", + "worker", + "--loglevel=INFO", + "--queues=high-priority,low-priority" +] +working_dir = "/app" diff --git a/python/django4-asgi/commands/run-worker b/python/django4-asgi/commands/run-worker new file mode 100755 index 000000000..2df2ee274 --- /dev/null +++ b/python/django4-asgi/commands/run-worker @@ -0,0 +1,23 @@ +#!/bin/bash + +set -eu + +cd /app + +# The `app` service builds the appsignal dist into the shared /integration +# volume and (for the Django apps) runs migrations, then serves HTTP. Wait for +# it to be reachable before installing here: that means the dist is built (so we +# install from it without racing the app's `hatch build`) and the schema is +# migrated (so this container never migrates). +echo "Waiting for the app service to be ready..." +until python3 -c "import socket; socket.create_connection(('app', 4001), 2).close()" >/dev/null 2>&1; do + echo "App not ready yet, retrying..." + sleep 2 +done + +echo "Installing dependencies" +pip3 install -r requirements.txt +pip3 install /integration/dist/* --force-reinstall + +echo "Starting worker processes" +/commands/processmon processmon.worker.toml diff --git a/python/django4-asgi/docker-compose.agent.yml b/python/django4-asgi/docker-compose.agent.yml index c6fe94d29..cb9bf46e7 100644 --- a/python/django4-asgi/docker-compose.agent.yml +++ b/python/django4-asgi/docker-compose.agent.yml @@ -8,3 +8,6 @@ services: downstream: environment: - APPSIGNAL_APP_NAME=python/django4-asgi-downstream + worker: + environment: + - APPSIGNAL_APP_NAME=python/django4-asgi-worker diff --git a/python/django4-asgi/docker-compose.collector.yml b/python/django4-asgi/docker-compose.collector.yml index 9f145d0ce..f230289ad 100644 --- a/python/django4-asgi/docker-compose.collector.yml +++ b/python/django4-asgi/docker-compose.collector.yml @@ -13,3 +13,8 @@ services: - APPSIGNAL_APP_NAME=python/django4-asgi-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=python/django4-asgi-collector + - APPSIGNAL_SERVICE_NAME=worker + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django4-asgi/docker-compose.shared.yml b/python/django4-asgi/docker-compose.shared.yml index a0b681a89..3b3d9b93f 100644 --- a/python/django4-asgi/docker-compose.shared.yml +++ b/python/django4-asgi/docker-compose.shared.yml @@ -47,6 +47,20 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (image-only; only `app` builds). + worker: + image: python/django4-asgi + command: /commands/run-worker + depends_on: + - postgres + - redis + env_file: + - postgres.env + - ../../appsignal.env + - ../../appsignal_key.env + volumes: + - ./app:/app + - ../integration:/integration tests: build: ../../support/server-tests image: server-tests diff --git a/python/django4-celery/app/processmon.toml b/python/django4-celery/app/processmon.toml index 0f26e1af2..86fb3bc76 100644 --- a/python/django4-celery/app/processmon.toml +++ b/python/django4-celery/app/processmon.toml @@ -9,13 +9,3 @@ args = [ "0.0.0.0:4001" ] working_dir = "/app" - -[processes.celery] -command = "celery" -args = [ - "--app=tasks", - "worker", - "--loglevel=INFO", - "--queues=high-priority,low-priority" -] -working_dir = "/app" diff --git a/python/django4-celery/app/processmon.worker.toml b/python/django4-celery/app/processmon.worker.toml new file mode 100644 index 000000000..5a1ff7183 --- /dev/null +++ b/python/django4-celery/app/processmon.worker.toml @@ -0,0 +1,15 @@ +[[paths_to_watch]] +path = "/app" + +# The worker service runs only the background worker. Work enqueued by the web +# `app` service is performed here, so it reports under the worker's own app name +# (agent mode) or `worker` service name (collector mode). +[processes.celery] +command = "celery" +args = [ + "--app=tasks", + "worker", + "--loglevel=INFO", + "--queues=high-priority,low-priority" +] +working_dir = "/app" diff --git a/python/django4-celery/commands/run-worker b/python/django4-celery/commands/run-worker new file mode 100755 index 000000000..2df2ee274 --- /dev/null +++ b/python/django4-celery/commands/run-worker @@ -0,0 +1,23 @@ +#!/bin/bash + +set -eu + +cd /app + +# The `app` service builds the appsignal dist into the shared /integration +# volume and (for the Django apps) runs migrations, then serves HTTP. Wait for +# it to be reachable before installing here: that means the dist is built (so we +# install from it without racing the app's `hatch build`) and the schema is +# migrated (so this container never migrates). +echo "Waiting for the app service to be ready..." +until python3 -c "import socket; socket.create_connection(('app', 4001), 2).close()" >/dev/null 2>&1; do + echo "App not ready yet, retrying..." + sleep 2 +done + +echo "Installing dependencies" +pip3 install -r requirements.txt +pip3 install /integration/dist/* --force-reinstall + +echo "Starting worker processes" +/commands/processmon processmon.worker.toml diff --git a/python/django4-celery/docker-compose.agent.yml b/python/django4-celery/docker-compose.agent.yml index dccbdb43b..519d5cb11 100644 --- a/python/django4-celery/docker-compose.agent.yml +++ b/python/django4-celery/docker-compose.agent.yml @@ -8,3 +8,6 @@ services: downstream: environment: - APPSIGNAL_APP_NAME=python/django4-celery-downstream + worker: + environment: + - APPSIGNAL_APP_NAME=python/django4-celery-worker diff --git a/python/django4-celery/docker-compose.collector.yml b/python/django4-celery/docker-compose.collector.yml index 57798d140..737099c7d 100644 --- a/python/django4-celery/docker-compose.collector.yml +++ b/python/django4-celery/docker-compose.collector.yml @@ -13,3 +13,8 @@ services: - APPSIGNAL_APP_NAME=python/django4-celery-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=python/django4-celery-collector + - APPSIGNAL_SERVICE_NAME=worker + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django4-celery/docker-compose.shared.yml b/python/django4-celery/docker-compose.shared.yml index d7c3e0755..6cd445fd3 100644 --- a/python/django4-celery/docker-compose.shared.yml +++ b/python/django4-celery/docker-compose.shared.yml @@ -52,6 +52,20 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (image-only; only `app` builds). + worker: + image: python/django4-celery + command: /commands/run-worker + depends_on: + - postgres + - redis + env_file: + - postgres.env + - ../../appsignal.env + - ../../appsignal_key.env + volumes: + - ./app:/app + - ../integration:/integration tests: build: ../../support/server-tests image: server-tests diff --git a/python/django4-wsgi/app/processmon.toml b/python/django4-wsgi/app/processmon.toml index 0e096c604..aa361888a 100644 --- a/python/django4-wsgi/app/processmon.toml +++ b/python/django4-wsgi/app/processmon.toml @@ -7,13 +7,3 @@ args = [ "appsignal_python_opentelemetry.wsgi" ] working_dir = "/app" - -[processes.celery] -command = "celery" -args = [ - "--app=tasks", - "worker", - "--loglevel=INFO", - "--queues=high-priority,low-priority" -] -working_dir = "/app" diff --git a/python/django4-wsgi/app/processmon.worker.toml b/python/django4-wsgi/app/processmon.worker.toml new file mode 100644 index 000000000..5a1ff7183 --- /dev/null +++ b/python/django4-wsgi/app/processmon.worker.toml @@ -0,0 +1,15 @@ +[[paths_to_watch]] +path = "/app" + +# The worker service runs only the background worker. Work enqueued by the web +# `app` service is performed here, so it reports under the worker's own app name +# (agent mode) or `worker` service name (collector mode). +[processes.celery] +command = "celery" +args = [ + "--app=tasks", + "worker", + "--loglevel=INFO", + "--queues=high-priority,low-priority" +] +working_dir = "/app" diff --git a/python/django4-wsgi/commands/run-worker b/python/django4-wsgi/commands/run-worker new file mode 100755 index 000000000..2df2ee274 --- /dev/null +++ b/python/django4-wsgi/commands/run-worker @@ -0,0 +1,23 @@ +#!/bin/bash + +set -eu + +cd /app + +# The `app` service builds the appsignal dist into the shared /integration +# volume and (for the Django apps) runs migrations, then serves HTTP. Wait for +# it to be reachable before installing here: that means the dist is built (so we +# install from it without racing the app's `hatch build`) and the schema is +# migrated (so this container never migrates). +echo "Waiting for the app service to be ready..." +until python3 -c "import socket; socket.create_connection(('app', 4001), 2).close()" >/dev/null 2>&1; do + echo "App not ready yet, retrying..." + sleep 2 +done + +echo "Installing dependencies" +pip3 install -r requirements.txt +pip3 install /integration/dist/* --force-reinstall + +echo "Starting worker processes" +/commands/processmon processmon.worker.toml diff --git a/python/django4-wsgi/docker-compose.agent.yml b/python/django4-wsgi/docker-compose.agent.yml index 40193c2ce..a9825089a 100644 --- a/python/django4-wsgi/docker-compose.agent.yml +++ b/python/django4-wsgi/docker-compose.agent.yml @@ -8,3 +8,6 @@ services: downstream: environment: - APPSIGNAL_APP_NAME=python/django4-wsgi-downstream + worker: + environment: + - APPSIGNAL_APP_NAME=python/django4-wsgi-worker diff --git a/python/django4-wsgi/docker-compose.collector.yml b/python/django4-wsgi/docker-compose.collector.yml index 3ca4fc163..094b540c0 100644 --- a/python/django4-wsgi/docker-compose.collector.yml +++ b/python/django4-wsgi/docker-compose.collector.yml @@ -13,3 +13,8 @@ services: - APPSIGNAL_APP_NAME=python/django4-wsgi-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=python/django4-wsgi-collector + - APPSIGNAL_SERVICE_NAME=worker + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django4-wsgi/docker-compose.shared.yml b/python/django4-wsgi/docker-compose.shared.yml index 8628011f8..73def3a13 100644 --- a/python/django4-wsgi/docker-compose.shared.yml +++ b/python/django4-wsgi/docker-compose.shared.yml @@ -47,6 +47,20 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (image-only; only `app` builds). + worker: + image: python/django4-wsgi + command: /commands/run-worker + depends_on: + - postgres + - redis + env_file: + - postgres.env + - ../../appsignal.env + - ../../appsignal_key.env + volumes: + - ./app:/app + - ../integration:/integration tests: build: ../../support/server-tests image: server-tests diff --git a/python/django5-celery/app/processmon.toml b/python/django5-celery/app/processmon.toml index 0f26e1af2..86fb3bc76 100644 --- a/python/django5-celery/app/processmon.toml +++ b/python/django5-celery/app/processmon.toml @@ -9,13 +9,3 @@ args = [ "0.0.0.0:4001" ] working_dir = "/app" - -[processes.celery] -command = "celery" -args = [ - "--app=tasks", - "worker", - "--loglevel=INFO", - "--queues=high-priority,low-priority" -] -working_dir = "/app" diff --git a/python/django5-celery/app/processmon.worker.toml b/python/django5-celery/app/processmon.worker.toml new file mode 100644 index 000000000..5a1ff7183 --- /dev/null +++ b/python/django5-celery/app/processmon.worker.toml @@ -0,0 +1,15 @@ +[[paths_to_watch]] +path = "/app" + +# The worker service runs only the background worker. Work enqueued by the web +# `app` service is performed here, so it reports under the worker's own app name +# (agent mode) or `worker` service name (collector mode). +[processes.celery] +command = "celery" +args = [ + "--app=tasks", + "worker", + "--loglevel=INFO", + "--queues=high-priority,low-priority" +] +working_dir = "/app" diff --git a/python/django5-celery/commands/run-worker b/python/django5-celery/commands/run-worker new file mode 100755 index 000000000..2df2ee274 --- /dev/null +++ b/python/django5-celery/commands/run-worker @@ -0,0 +1,23 @@ +#!/bin/bash + +set -eu + +cd /app + +# The `app` service builds the appsignal dist into the shared /integration +# volume and (for the Django apps) runs migrations, then serves HTTP. Wait for +# it to be reachable before installing here: that means the dist is built (so we +# install from it without racing the app's `hatch build`) and the schema is +# migrated (so this container never migrates). +echo "Waiting for the app service to be ready..." +until python3 -c "import socket; socket.create_connection(('app', 4001), 2).close()" >/dev/null 2>&1; do + echo "App not ready yet, retrying..." + sleep 2 +done + +echo "Installing dependencies" +pip3 install -r requirements.txt +pip3 install /integration/dist/* --force-reinstall + +echo "Starting worker processes" +/commands/processmon processmon.worker.toml diff --git a/python/django5-celery/docker-compose.agent.yml b/python/django5-celery/docker-compose.agent.yml index 8ea10a071..4bddff1a6 100644 --- a/python/django5-celery/docker-compose.agent.yml +++ b/python/django5-celery/docker-compose.agent.yml @@ -8,3 +8,6 @@ services: downstream: environment: - APPSIGNAL_APP_NAME=python/django5-celery-downstream + worker: + environment: + - APPSIGNAL_APP_NAME=python/django5-celery-worker diff --git a/python/django5-celery/docker-compose.collector.yml b/python/django5-celery/docker-compose.collector.yml index 196f9239a..03ec19794 100644 --- a/python/django5-celery/docker-compose.collector.yml +++ b/python/django5-celery/docker-compose.collector.yml @@ -13,3 +13,8 @@ services: - APPSIGNAL_APP_NAME=python/django5-celery-collector - APPSIGNAL_SERVICE_NAME=downstream - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=python/django5-celery-collector + - APPSIGNAL_SERVICE_NAME=worker + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/django5-celery/docker-compose.shared.yml b/python/django5-celery/docker-compose.shared.yml index 0717662e9..0c3ecfb75 100644 --- a/python/django5-celery/docker-compose.shared.yml +++ b/python/django5-celery/docker-compose.shared.yml @@ -52,6 +52,23 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (image-only; only `app` builds). + worker: + image: python/django5-celery + command: /commands/run-worker + depends_on: + - postgres + - redis + env_file: + - postgres.env + - ../../appsignal.env + - ../../appsignal_key.env + environment: + - APPSIGNAL_APP_ENV=dev + - APPSIGNAL_LOG_LEVEL=trace + volumes: + - ./app:/app + - ../integration:/integration tests: build: ../../support/server-tests image: server-tests diff --git a/python/flask-pika/app/processmon.toml b/python/flask-pika/app/processmon.toml index a9842fc15..c74bb0fa7 100644 --- a/python/flask-pika/app/processmon.toml +++ b/python/flask-pika/app/processmon.toml @@ -9,10 +9,3 @@ args = [ "--port=4001" ] working_dir = "/app" - -[processes.consumer] -command = "python" -args = [ - "pika_consumer.py" -] -working_dir = "/app" diff --git a/python/flask-pika/app/processmon.worker.toml b/python/flask-pika/app/processmon.worker.toml new file mode 100644 index 000000000..560f6f307 --- /dev/null +++ b/python/flask-pika/app/processmon.worker.toml @@ -0,0 +1,12 @@ +[[paths_to_watch]] +path = "/app" + +# The worker service runs only the background worker. Work enqueued by the web +# `app` service is performed here, so it reports under the worker's own app name +# (agent mode) or `worker` service name (collector mode). +[processes.consumer] +command = "python" +args = [ + "pika_consumer.py" +] +working_dir = "/app" diff --git a/python/flask-pika/commands/run-worker b/python/flask-pika/commands/run-worker new file mode 100755 index 000000000..2df2ee274 --- /dev/null +++ b/python/flask-pika/commands/run-worker @@ -0,0 +1,23 @@ +#!/bin/bash + +set -eu + +cd /app + +# The `app` service builds the appsignal dist into the shared /integration +# volume and (for the Django apps) runs migrations, then serves HTTP. Wait for +# it to be reachable before installing here: that means the dist is built (so we +# install from it without racing the app's `hatch build`) and the schema is +# migrated (so this container never migrates). +echo "Waiting for the app service to be ready..." +until python3 -c "import socket; socket.create_connection(('app', 4001), 2).close()" >/dev/null 2>&1; do + echo "App not ready yet, retrying..." + sleep 2 +done + +echo "Installing dependencies" +pip3 install -r requirements.txt +pip3 install /integration/dist/* --force-reinstall + +echo "Starting worker processes" +/commands/processmon processmon.worker.toml diff --git a/python/flask-pika/docker-compose.agent.yml b/python/flask-pika/docker-compose.agent.yml index f91fd9c65..308b995a3 100644 --- a/python/flask-pika/docker-compose.agent.yml +++ b/python/flask-pika/docker-compose.agent.yml @@ -5,3 +5,6 @@ services: app: environment: - APPSIGNAL_APP_NAME=python/flask-pika + worker: + environment: + - APPSIGNAL_APP_NAME=python/flask-pika-worker diff --git a/python/flask-pika/docker-compose.collector.yml b/python/flask-pika/docker-compose.collector.yml index 973390fbf..81415b1be 100644 --- a/python/flask-pika/docker-compose.collector.yml +++ b/python/flask-pika/docker-compose.collector.yml @@ -8,3 +8,8 @@ services: - APPSIGNAL_APP_NAME=python/flask-pika-collector - APPSIGNAL_SERVICE_NAME=app - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 + worker: + environment: + - APPSIGNAL_APP_NAME=python/flask-pika-collector + - APPSIGNAL_SERVICE_NAME=worker + - APPSIGNAL_COLLECTOR_ENDPOINT=http://appsignal-collector:8099 diff --git a/python/flask-pika/docker-compose.shared.yml b/python/flask-pika/docker-compose.shared.yml index 1f93057e3..5fff1a869 100644 --- a/python/flask-pika/docker-compose.shared.yml +++ b/python/flask-pika/docker-compose.shared.yml @@ -22,6 +22,18 @@ services: volumes: - ./app:/app - ../integration:/integration + # Runs only the background worker (image-only; only `app` builds). + worker: + image: python/flask + command: /commands/run-worker + depends_on: + - rabbitmq + env_file: + - ../../appsignal.env + - ../../appsignal_key.env + volumes: + - ./app:/app + - ../integration:/integration tests: build: ../../support/server-tests image: server-tests From 89e63c267bfde7b0b6a0e80a5be9c0837f1c8e92 Mon Sep 17 00:00:00 2001 From: Noemi Lapresta Date: Mon, 13 Jul 2026 16:19:00 +0200 Subject: [PATCH 12/12] Wait for migrations before starting downstream The downstream service runs a second web server and, for the DB-backed setups, a queue worker (Que) that polls a table a migration creates. Like run-worker before this, run-downstream only waited for gems, so it could start before the app migrated, crash, and stay dead, because processmon restarts a process on file changes but not when it exits. Wait for the app to serve HTTP before starting downstream. The app only starts its web server after it migrates, so a reachable app means the schema is ready. This matches the run-worker fix. --- ruby/rails6-shoryuken/commands/run-downstream | 10 ++++++++++ ruby/rails7-sidekiq/commands/run-downstream | 10 ++++++++++ ruby/rails8-que/commands/run-downstream | 10 ++++++++++ ruby/rails8-resque/commands/run-downstream | 10 ++++++++++ ruby/webmachine2/commands/run-downstream | 10 ++++++++++ 5 files changed, 50 insertions(+) diff --git a/ruby/rails6-shoryuken/commands/run-downstream b/ruby/rails6-shoryuken/commands/run-downstream index df6f7e9d8..159334c6f 100755 --- a/ruby/rails6-shoryuken/commands/run-downstream +++ b/ruby/rails6-shoryuken/commands/run-downstream @@ -18,6 +18,16 @@ until bundle check >/dev/null 2>&1; do sleep 2 done +# Also wait for the app to finish migrating. A DB-backed queue worker (e.g. Que) +# polls a table created by a migration; if it starts first it crashes, and +# processmon does not restart a crashed process. The app only serves HTTP after +# it migrates, so a reachable app means the schema is ready. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App 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" diff --git a/ruby/rails7-sidekiq/commands/run-downstream b/ruby/rails7-sidekiq/commands/run-downstream index 2a08e5af5..a5dc2ea89 100755 --- a/ruby/rails7-sidekiq/commands/run-downstream +++ b/ruby/rails7-sidekiq/commands/run-downstream @@ -14,5 +14,15 @@ until bundle check >/dev/null 2>&1; do sleep 2 done +# Also wait for the app to finish migrating. A DB-backed queue worker (e.g. Que) +# polls a table created by a migration; if it starts first it crashes, and +# processmon does not restart a crashed process. The app only serves HTTP after +# it migrates, so a reachable app means the schema is ready. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting downstream processes" /commands/processmon processmon.downstream.toml diff --git a/ruby/rails8-que/commands/run-downstream b/ruby/rails8-que/commands/run-downstream index fc89db176..735a24059 100755 --- a/ruby/rails8-que/commands/run-downstream +++ b/ruby/rails8-que/commands/run-downstream @@ -13,5 +13,15 @@ until bundle check >/dev/null 2>&1; do sleep 2 done +# Also wait for the app to finish migrating. A DB-backed queue worker (e.g. Que) +# polls a table created by a migration; if it starts first it crashes, and +# processmon does not restart a crashed process. The app only serves HTTP after +# it migrates, so a reachable app means the schema is ready. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + echo "Starting downstream Que worker" /commands/processmon processmon.downstream.toml diff --git a/ruby/rails8-resque/commands/run-downstream b/ruby/rails8-resque/commands/run-downstream index 8b158a06c..11b4bba09 100755 --- a/ruby/rails8-resque/commands/run-downstream +++ b/ruby/rails8-resque/commands/run-downstream @@ -13,6 +13,16 @@ until bundle check >/dev/null 2>&1; do sleep 2 done +# Also wait for the app to finish migrating. A DB-backed queue worker (e.g. Que) +# polls a table created by a migration; if it starts first it crashes, and +# processmon does not restart a crashed process. The app only serves HTTP after +# it migrates, so a reachable app means the schema is ready. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App 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 diff --git a/ruby/webmachine2/commands/run-downstream b/ruby/webmachine2/commands/run-downstream index 4d6d25395..678909c71 100755 --- a/ruby/webmachine2/commands/run-downstream +++ b/ruby/webmachine2/commands/run-downstream @@ -17,6 +17,16 @@ until bundle check >/dev/null 2>&1; do sleep 2 done +# Also wait for the app to finish migrating. A DB-backed queue worker (e.g. Que) +# polls a table created by a migration; if it starts first it crashes, and +# processmon does not restart a crashed process. The app only serves HTTP after +# it migrates, so a reachable app means the schema is ready. +echo "Waiting for the app service to finish migrating..." +until curl -s -o /dev/null "http://app:4001/up" 2>/dev/null; do + echo "App not ready yet, retrying..." + sleep 2 +done + # Runs the same webmachine app, which binds to PORT (4002 here) via config.ru. echo "Starting downstream webmachine app" /commands/processmon processmon.toml