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/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/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-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/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 83d8ee52c..cb9bf46e7 100644
--- a/python/django4-asgi/docker-compose.agent.yml
+++ b/python/django4-asgi/docker-compose.agent.yml
@@ -5,3 +5,9 @@ services:
app:
environment:
- APPSIGNAL_APP_NAME=python/django4-asgi
+ 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 db91024e3..f230289ad 100644
--- a/python/django4-asgi/docker-compose.collector.yml
+++ b/python/django4-asgi/docker-compose.collector.yml
@@ -6,4 +6,15 @@ services:
app:
environment:
- 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
+ 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 ce3bbb9ac..3b3d9b93f 100644
--- a/python/django4-asgi/docker-compose.shared.yml
+++ b/python/django4-asgi/docker-compose.shared.yml
@@ -28,6 +28,36 @@ 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
+ # 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
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/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-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/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 9ec6f3634..519d5cb11 100644
--- a/python/django4-celery/docker-compose.agent.yml
+++ b/python/django4-celery/docker-compose.agent.yml
@@ -5,3 +5,9 @@ services:
app:
environment:
- APPSIGNAL_APP_NAME=python/django4-celery
+ 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 e30d90ef7..737099c7d 100644
--- a/python/django4-celery/docker-compose.collector.yml
+++ b/python/django4-celery/docker-compose.collector.yml
@@ -6,4 +6,15 @@ services:
app:
environment:
- 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
+ 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 271d75c38..6cd445fd3 100644
--- a/python/django4-celery/docker-compose.shared.yml
+++ b/python/django4-celery/docker-compose.shared.yml
@@ -28,6 +28,41 @@ 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
+ # 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
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/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-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/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 483db63ba..a9825089a 100644
--- a/python/django4-wsgi/docker-compose.agent.yml
+++ b/python/django4-wsgi/docker-compose.agent.yml
@@ -5,3 +5,9 @@ services:
app:
environment:
- APPSIGNAL_APP_NAME=python/django4-wsgi
+ 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 d3c6d9504..094b540c0 100644
--- a/python/django4-wsgi/docker-compose.collector.yml
+++ b/python/django4-wsgi/docker-compose.collector.yml
@@ -6,4 +6,15 @@ services:
app:
environment:
- 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
+ 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 abae6b943..73def3a13 100644
--- a/python/django4-wsgi/docker-compose.shared.yml
+++ b/python/django4-wsgi/docker-compose.shared.yml
@@ -28,6 +28,36 @@ 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
+ # 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
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/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-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/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 fbffc1731..4bddff1a6 100644
--- a/python/django5-celery/docker-compose.agent.yml
+++ b/python/django5-celery/docker-compose.agent.yml
@@ -5,3 +5,9 @@ services:
app:
environment:
- APPSIGNAL_APP_NAME=python/django5-celery
+ 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 932dac9a5..03ec19794 100644
--- a/python/django5-celery/docker-compose.collector.yml
+++ b/python/django5-celery/docker-compose.collector.yml
@@ -6,4 +6,15 @@ services:
app:
environment:
- 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
+ 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 71372be60..0c3ecfb75 100644
--- a/python/django5-celery/docker-compose.shared.yml
+++ b/python/django5-celery/docker-compose.shared.yml
@@ -28,6 +28,42 @@ 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:
+ - ./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:
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/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 c24ef4fce..81415b1be 100644
--- a/python/flask-pika/docker-compose.collector.yml
+++ b/python/flask-pika/docker-compose.collector.yml
@@ -6,4 +6,10 @@ services:
app:
environment:
- 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
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/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/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 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..159334c6f
--- /dev/null
+++ b/ruby/rails6-shoryuken/commands/run-downstream
@@ -0,0 +1,37 @@
+#!/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
+
+# 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"
+bundle exec shoryuken sqs create downstream || true
+
+echo "Starting downstream Shoryuken worker"
+/commands/processmon processmon.downstream.toml
diff --git a/ruby/rails6-shoryuken/commands/run-worker b/ruby/rails6-shoryuken/commands/run-worker
new file mode 100755
index 000000000..e1aa38eca
--- /dev/null
+++ b/ruby/rails6-shoryuken/commands/run-worker
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /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/rails6-shoryuken/docker-compose.agent.yml b/ruby/rails6-shoryuken/docker-compose.agent.yml
index b6ca95b17..47eb4701d 100644
--- a/ruby/rails6-shoryuken/docker-compose.agent.yml
+++ b/ruby/rails6-shoryuken/docker-compose.agent.yml
@@ -5,3 +5,9 @@ services:
app:
environment:
- APPSIGNAL_APP_NAME=rails6-shoryuken
+ 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 baa91282f..467cbc61f 100644
--- a/ruby/rails6-shoryuken/docker-compose.collector.yml
+++ b/ruby/rails6-shoryuken/docker-compose.collector.yml
@@ -6,4 +6,15 @@ 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-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 d2df5cfc0..e8a1aecef 100644
--- a/ruby/rails6-shoryuken/docker-compose.shared.yml
+++ b/ruby/rails6-shoryuken/docker-compose.shared.yml
@@ -23,6 +23,41 @@ 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:
+ 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
+ # 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..e1aa38eca
--- /dev/null
+++ b/ruby/rails7-delayed-job/commands/run-worker
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /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/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 9d25d7abc..13d1f7bbd 100644
--- a/ruby/rails7-delayed-job/docker-compose.collector.yml
+++ b/ruby/rails7-delayed-job/docker-compose.collector.yml
@@ -6,4 +6,10 @@ services:
app:
environment:
- 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..e1aa38eca
--- /dev/null
+++ b/ruby/rails7-goodjob/commands/run-worker
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /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/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 501050d69..631c2ff67 100644
--- a/ruby/rails7-goodjob/docker-compose.collector.yml
+++ b/ruby/rails7-goodjob/docker-compose.collector.yml
@@ -6,4 +6,10 @@ services:
app:
environment:
- 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-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/app/Gemfile b/ruby/rails7-sidekiq/app/Gemfile
index b01d778ad..e4b48f96f 100644
--- a/ruby/rails7-sidekiq/app/Gemfile
+++ b/ruby/rails7-sidekiq/app/Gemfile
@@ -14,6 +14,16 @@ gem "excon"
gem "http"
gem "faraday"
+# OpenTelemetry gems for AppSignal collector mode. Loaded only when
+# APPSIGNAL_COLLECTOR_ENDPOINT is set; keep versions in sync with appsignal's
+# lib/appsignal/opentelemetry/dependencies.rb.
+gem "opentelemetry-sdk", ">= 1.8.0"
+gem "opentelemetry-metrics-sdk", ">= 0.7.1"
+gem "opentelemetry-logs-sdk", ">= 0.2.0"
+gem "opentelemetry-exporter-otlp", ">= 0.30.0"
+gem "opentelemetry-exporter-otlp-metrics", ">= 0.4.0"
+gem "opentelemetry-exporter-otlp-logs", ">= 0.2.0"
+
# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails]
gem "sprockets-rails"
diff --git a/ruby/rails7-sidekiq/app/Gemfile.lock b/ruby/rails7-sidekiq/app/Gemfile.lock
index 2dee8db21..8b9485392 100644
--- a/ruby/rails7-sidekiq/app/Gemfile.lock
+++ b/ruby/rails7-sidekiq/app/Gemfile.lock
@@ -122,6 +122,11 @@ GEM
rake
globalid (1.2.1)
activesupport (>= 6.1)
+ google-protobuf (4.33.6)
+ bigdecimal
+ rake (>= 13)
+ googleapis-common-protos-types (1.22.0)
+ google-protobuf (~> 4.26)
http (5.3.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
@@ -178,6 +183,56 @@ GEM
nio4r (2.7.3)
nokogiri (1.16.5-aarch64-linux)
racc (~> 1.4)
+ opentelemetry-api (1.8.0)
+ logger
+ opentelemetry-common (0.23.0)
+ opentelemetry-api (~> 1.0)
+ opentelemetry-exporter-otlp (0.32.0)
+ google-protobuf (>= 3.18)
+ googleapis-common-protos-types (~> 1.3)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-sdk (~> 1.10)
+ opentelemetry-semantic_conventions
+ opentelemetry-exporter-otlp-logs (0.3.0)
+ google-protobuf (>= 3.18)
+ googleapis-common-protos-types (~> 1.3)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-logs-api (~> 0.1)
+ opentelemetry-logs-sdk (~> 0.1)
+ opentelemetry-sdk
+ opentelemetry-semantic_conventions
+ opentelemetry-exporter-otlp-metrics (0.7.0)
+ google-protobuf (>= 3.18, < 5.0)
+ googleapis-common-protos-types (~> 1.3)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-metrics-api (~> 0.2)
+ opentelemetry-metrics-sdk (~> 0.5)
+ opentelemetry-sdk (~> 1.2)
+ opentelemetry-semantic_conventions
+ opentelemetry-logs-api (0.2.0)
+ opentelemetry-api (~> 1.0)
+ opentelemetry-logs-sdk (0.4.0)
+ opentelemetry-api (~> 1.2)
+ opentelemetry-logs-api (~> 0.1)
+ opentelemetry-sdk (~> 1.3)
+ opentelemetry-metrics-api (0.4.0)
+ opentelemetry-api (~> 1.0)
+ opentelemetry-metrics-sdk (0.12.0)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-metrics-api (~> 0.2)
+ opentelemetry-sdk (~> 1.2)
+ opentelemetry-registry (0.4.0)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-sdk (1.10.0)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-registry (~> 0.2)
+ opentelemetry-semantic_conventions
+ opentelemetry-semantic_conventions (1.36.0)
+ opentelemetry-api (~> 1.0)
pg (1.5.4)
psych (5.1.2)
stringio
@@ -309,6 +364,12 @@ DEPENDENCIES
http
importmap-rails
jbuilder
+ opentelemetry-exporter-otlp (>= 0.30.0)
+ opentelemetry-exporter-otlp-logs (>= 0.2.0)
+ opentelemetry-exporter-otlp-metrics (>= 0.4.0)
+ opentelemetry-logs-sdk (>= 0.2.0)
+ opentelemetry-metrics-sdk (>= 0.7.1)
+ opentelemetry-sdk (>= 1.8.0)
pg (~> 1.1)
puma (~> 6.0)
rails (~> 7.1.0)
diff --git a/ruby/rails7-sidekiq/app/app/controllers/requests_controller.rb b/ruby/rails7-sidekiq/app/app/controllers/requests_controller.rb
index e4af0eb3f..0cd75c41c 100644
--- a/ruby/rails7-sidekiq/app/app/controllers/requests_controller.rb
+++ b/ruby/rails7-sidekiq/app/app/controllers/requests_controller.rb
@@ -1,13 +1,21 @@
class RequestsController < ApplicationController
skip_forgery_protection
+ # The HTTP client actions call a second, separately-instrumented app (the
+ # `downstream` service) so the injected `traceparent` is extracted there and
+ # the trace spans both apps. Falls back to calling this app itself when
+ # DOWNSTREAM_URL is unset, so the setup still works as a single service.
+ def self.downstream_url(path)
+ "#{ENV.fetch("DOWNSTREAM_URL", "http://localhost:4001")}#{path}"
+ end
+
def perform_excon_get
- handle_response Excon.get("http://localhost:4001/requests/excon_get?query=param")
+ handle_response Excon.get(self.class.downstream_url("/requests/excon_get?query=param"))
end
def perform_excon_post
handle_response Excon.post(
- "http://localhost:4001/requests/excon_post",
+ self.class.downstream_url("/requests/excon_post"),
:body => URI.encode_www_form(:language => "ruby", :class => "fog"),
:headers => { "Content-Type" => "application/x-www-form-urlencoded" }
)
@@ -15,7 +23,7 @@ def perform_excon_post
def perform_excon_put
handle_response Excon.put(
- "http://localhost:4001/requests/excon_put",
+ self.class.downstream_url("/requests/excon_put"),
:body => URI.encode_www_form(:language => "ruby", :class => "fog"),
:headers => { "Content-Type" => "application/x-www-form-urlencoded" }
)
@@ -23,26 +31,28 @@ def perform_excon_put
def perform_excon_delete
handle_response Excon.delete(
- "http://localhost:4001/requests/excon_delete",
+ self.class.downstream_url("/requests/excon_delete"),
:body => URI.encode_www_form(:language => "ruby", :class => "fog"),
:headers => { "Content-Type" => "application/x-www-form-urlencoded" }
)
end
def perform_excon_head
- handle_response Excon.head("http://localhost:4001/requests/excon_get")
+ handle_response Excon.head(self.class.downstream_url("/requests/excon_get"))
end
def perform_excon_options
- handle_response Excon.options("http://localhost:4001/requests/excon_options")
+ handle_response Excon.options(self.class.downstream_url("/requests/excon_options"))
end
def perform_excon_trace
- handle_response Excon.trace("http://localhost:4001/requests/excon_trace")
+ handle_response Excon.trace(self.class.downstream_url("/requests/excon_trace"))
end
+ # Hits the downstream app so the request span continues into it. (Previously
+ # this called example.com, which is uninstrumented, so nothing continued.)
def perform_http_rb_get
- HTTP.get("https://example.com")
+ HTTP.get(self.class.downstream_url("/requests/http_rb_target"))
redirect_to requests_path, :notice => %(Request "#{params[:action]}" made)
end
@@ -51,14 +61,14 @@ def perform_http_rb_get
# These actions exercise that path so it records a `request.http_rb` event.
def perform_http_rb_headers
HTTP.headers("X-Custom-Header" => "AppSignal")
- .get("http://localhost:4001/requests/http_rb_target")
+ .get(self.class.downstream_url("/requests/http_rb_target"))
redirect_to requests_path, :notice => %(Request "#{params[:action]}" made)
end
# A followed redirect should stay a single `request.http_rb` event spanning
# every hop, not one event per hop.
def perform_http_rb_follow
- HTTP.follow.get("http://localhost:4001/requests/http_rb_redirect")
+ HTTP.follow.get(self.class.downstream_url("/requests/http_rb_redirect"))
redirect_to requests_path, :notice => %(Request "#{params[:action]}" made)
end
@@ -75,12 +85,12 @@ def http_rb_redirect
# suppressing the downstream Net::HTTP event: the request should be recorded
# once, as a `request.faraday` event.
def perform_faraday_get
- handle_response Faraday.get("http://localhost:4001/requests/faraday_get?query=param")
+ handle_response Faraday.get(self.class.downstream_url("/requests/faraday_get?query=param"))
end
def perform_faraday_post
handle_response Faraday.post(
- "http://localhost:4001/requests/faraday_post",
+ self.class.downstream_url("/requests/faraday_post"),
URI.encode_www_form(:language => "ruby", :library => "faraday"),
"Content-Type" => "application/x-www-form-urlencoded"
)
diff --git a/ruby/rails7-sidekiq/app/app/controllers/workers_controller.rb b/ruby/rails7-sidekiq/app/app/controllers/workers_controller.rb
index 478b5a8f0..9c96e812d 100644
--- a/ruby/rails7-sidekiq/app/app/controllers/workers_controller.rb
+++ b/ruby/rails7-sidekiq/app/app/controllers/workers_controller.rb
@@ -54,6 +54,14 @@ def queue
redirect_to({ :action => :index }, :notice => "Worker queued")
end
+ # Enqueue a job onto the `downstream` queue, which only the separately
+ # instrumented `downstream` service drains. In collector mode this shows job
+ # trace propagation across two services.
+ def cross_service
+ DownstreamWorker.perform_async("Cross-service test")
+ redirect_to({ :action => :index }, :notice => "Cross-service worker queued")
+ end
+
def handle_sidekiq(worker, args:, test:, future:)
if future
worker.perform_in(DELAY_DURATION.from_now, *args)
diff --git a/ruby/rails7-sidekiq/app/app/views/workers/index.html.erb b/ruby/rails7-sidekiq/app/app/views/workers/index.html.erb
index 8297b93fb..31b862ccf 100644
--- a/ruby/rails7-sidekiq/app/app/views/workers/index.html.erb
+++ b/ruby/rails7-sidekiq/app/app/views/workers/index.html.erb
@@ -21,6 +21,16 @@
<% end %>
+Queue a cross-service Sidekiq worker
+
+Enqueues a job onto the downstream queue, performed by the
+separately-instrumented downstream service. In collector mode the job's trace
+links back to the enqueue span across both apps.
+
+
+ - <%= link_to "DownstreamWorker (cross-service)", cross_service_workers_path %>
+
+
Sidekiq monitoring page
diff --git a/ruby/rails7-sidekiq/app/app/workers/downstream_worker.rb b/ruby/rails7-sidekiq/app/app/workers/downstream_worker.rb
new file mode 100644
index 000000000..f1f737627
--- /dev/null
+++ b/ruby/rails7-sidekiq/app/app/workers/downstream_worker.rb
@@ -0,0 +1,14 @@
+# Enqueued by the `app` service but performed by the `downstream` service, which
+# is the only one draining the `downstream` queue. In collector mode the trace
+# context rides along on the job payload, so the job's trace (under the
+# downstream app) links back to the enqueue span (under this app) -- job trace
+# propagation across two services.
+class DownstreamWorker
+ include Sidekiq::Worker
+ sidekiq_options :queue => "downstream"
+
+ def perform(argument = nil)
+ sleep 1
+ puts "downstream performed #{argument}!"
+ end
+end
diff --git a/ruby/rails7-sidekiq/app/config/environments/development.rb b/ruby/rails7-sidekiq/app/config/environments/development.rb
index 2e7fb486b..23e690b41 100644
--- a/ruby/rails7-sidekiq/app/config/environments/development.rb
+++ b/ruby/rails7-sidekiq/app/config/environments/development.rb
@@ -73,4 +73,9 @@
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true
+
+ # Permit the `downstream` service hostname so the HTTP client actions can reach
+ # the second app by its compose service name (Rails host authorization blocks
+ # unknown hosts otherwise).
+ config.hosts << "downstream"
end
diff --git a/ruby/rails7-sidekiq/app/config/routes.rb b/ruby/rails7-sidekiq/app/config/routes.rb
index f180f697e..a73a7ef53 100644
--- a/ruby/rails7-sidekiq/app/config/routes.rb
+++ b/ruby/rails7-sidekiq/app/config/routes.rb
@@ -19,6 +19,7 @@ class SinatraApp < Sinatra::Base
resources :workers do
collection do
get :queue
+ get :cross_service
end
end
mount Sidekiq::Web => "/sidekiq"
diff --git a/ruby/rails7-sidekiq/app/processmon.downstream.toml b/ruby/rails7-sidekiq/app/processmon.downstream.toml
new file mode 100644
index 000000000..96516951a
--- /dev/null
+++ b/ruby/rails7-sidekiq/app/processmon.downstream.toml
@@ -0,0 +1,34 @@
+[[paths_to_watch]]
+path = "/app"
+ignore = [
+ "tmp",
+ "log",
+ "node_modules",
+ "public/packs"
+]
+
+[[paths_to_watch]]
+path = "/integration"
+
+# Only the downstream service drains this queue, so jobs enqueued by `app` are
+# performed here, under the downstream app name.
+[processes.sidekiq]
+command = "bundle"
+args = [
+ "exec",
+ "sidekiq",
+ "--queue=downstream"
+]
+working_dir = "/app"
+
+# A distinct pidfile so this Rails server doesn't collide with the `app`
+# service's server, which shares the same mounted /app directory.
+[processes.rails]
+command = "bin/rails"
+args = [
+ "server",
+ "--binding=0.0.0.0",
+ "--port=4002",
+ "--pid=/app/tmp/pids/downstream.pid"
+]
+working_dir = "/app"
diff --git a/ruby/rails7-sidekiq/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-downstream b/ruby/rails7-sidekiq/commands/run-downstream
new file mode 100755
index 000000000..a5dc2ea89
--- /dev/null
+++ b/ruby/rails7-sidekiq/commands/run-downstream
@@ -0,0 +1,28 @@
+#!/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
+
+# 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/rails7-sidekiq/commands/run-worker b/ruby/rails7-sidekiq/commands/run-worker
new file mode 100755
index 000000000..e1aa38eca
--- /dev/null
+++ b/ruby/rails7-sidekiq/commands/run-worker
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /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/docker-compose.agent.yml b/ruby/rails7-sidekiq/docker-compose.agent.yml
new file mode 100644
index 000000000..526b1e480
--- /dev/null
+++ b/ruby/rails7-sidekiq/docker-compose.agent.yml
@@ -0,0 +1,13 @@
+include:
+ - path: docker-compose.shared.yml
+
+services:
+ app:
+ environment:
+ - APPSIGNAL_APP_NAME=ruby/rails7-sidekiq
+ 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
new file mode 100644
index 000000000..e2a3c1d76
--- /dev/null
+++ b/ruby/rails7-sidekiq/docker-compose.collector.yml
@@ -0,0 +1,20 @@
+include:
+ - path: docker-compose.shared.yml
+ - path: ../../support/collector/docker-compose.yml
+
+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-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
new file mode 100644
index 000000000..c69a21455
--- /dev/null
+++ b/ruby/rails7-sidekiq/docker-compose.shared.yml
@@ -0,0 +1,90 @@
+version: '3.8'
+
+include:
+ - path: ../../support/bot/docker-compose.yml
+
+services:
+ postgres:
+ image: postgres
+ env_file:
+ - postgres.env
+ redis:
+ image: "redis:6.2.7"
+ app:
+ build: .
+ image: ruby/rails7-sidekiq
+ depends_on:
+ - postgres
+ - redis
+ ports:
+ - "4001:4001"
+ environment:
+ - PORT=4001
+ # The HTTP client actions call this second, separately-instrumented app so
+ # the trace propagates across services. Falls back to calling itself when
+ # unset (single-service runs).
+ - DOWNSTREAM_URL=http://downstream:4002
+ env_file:
+ - postgres.env
+ - redis.env
+ - ../../appsignal.env
+ - ../../appsignal_key.env
+ volumes:
+ - ./app:/app
+ - ../integration:/integration
+ # A second instance of the same Rails image, running as a distinct AppSignal
+ # app. It receives the HTTP requests the `app` service makes (trace context
+ # extraction) and drains the `downstream` Sidekiq queue that `app` enqueues to
+ # (job trace propagation). Not published externally; `app` reaches it by name.
+ downstream:
+ image: ruby/rails7-sidekiq
+ command: /commands/run-downstream
+ depends_on:
+ - postgres
+ - redis
+ environment:
+ - PORT=4002
+ env_file:
+ - postgres.env
+ - redis.env
+ - ../../appsignal.env
+ - ../../appsignal_key.env
+ volumes:
+ - ./app:/app
+ - ../integration:/integration
+ # 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
+ profiles:
+ - tests
+ depends_on:
+ - app
+ environment:
+ - APP_NAME=ruby/rails7-sidekiq
+ - APP_URL=http://app:4001
diff --git a/ruby/rails7-sidekiq/docker-compose.yml b/ruby/rails7-sidekiq/docker-compose.yml
deleted file mode 100644
index d1999f2d3..000000000
--- a/ruby/rails7-sidekiq/docker-compose.yml
+++ /dev/null
@@ -1,41 +0,0 @@
-version: '3.8'
-
-include:
- - path: ../../support/bot/docker-compose.yml
-
-services:
- postgres:
- image: postgres
- env_file:
- - postgres.env
- redis:
- image: "redis:6.2.7"
- app:
- build: .
- image: ruby/rails7-sidekiq
- depends_on:
- - postgres
- - redis
- ports:
- - "4001:4001"
- environment:
- - APPSIGNAL_APP_NAME=ruby-rails7-sidekiq
- - PORT=4001
- env_file:
- - postgres.env
- - redis.env
- - ../../appsignal.env
- - ../../appsignal_key.env
- volumes:
- - ./app:/app
- - ../integration:/integration
- tests:
- build: ../../support/server-tests
- image: server-tests
- profiles:
- - tests
- depends_on:
- - app
- environment:
- - APP_NAME=ruby/rails7-sidekiq
- - APP_URL=http://app:4001
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/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..e1aa38eca
--- /dev/null
+++ b/ruby/rails7-solid-queue/commands/run-worker
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /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/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 ef77bbc19..229c964f7 100644
--- a/ruby/rails7-solid-queue/docker-compose.collector.yml
+++ b/ruby/rails7-solid-queue/docker-compose.collector.yml
@@ -6,4 +6,10 @@ services:
app:
environment:
- 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..e1aa38eca
--- /dev/null
+++ b/ruby/rails8-delayed-job/commands/run-worker
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /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/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 0406d0325..a6fa459b5 100644
--- a/ruby/rails8-delayed-job/docker-compose.collector.yml
+++ b/ruby/rails8-delayed-job/docker-compose.collector.yml
@@ -6,4 +6,10 @@ services:
app:
environment:
- 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/app/controllers/workers_controller.rb b/ruby/rails8-que/app/app/controllers/workers_controller.rb
index 6cb43926a..42e492e27 100644
--- a/ruby/rails8-que/app/app/controllers/workers_controller.rb
+++ b/ruby/rails8-que/app/app/controllers/workers_controller.rb
@@ -38,6 +38,14 @@ def queue
redirect_to({ :action => :index }, :notice => "Worker queued")
end
+ # Enqueue a native Que job onto the `downstream` queue, which only the
+ # separately instrumented downstream service drains. In collector mode this
+ # shows job trace propagation across two services.
+ def cross_service
+ DownstreamJob.enqueue("Cross-service test", :job_options => { :queue => "downstream" })
+ redirect_to({ :action => :index }, :notice => "Cross-service job queued")
+ end
+
private
def handle_que(worker, args)
diff --git a/ruby/rails8-que/app/app/jobs/downstream_job.rb b/ruby/rails8-que/app/app/jobs/downstream_job.rb
new file mode 100644
index 000000000..c85f44033
--- /dev/null
+++ b/ruby/rails8-que/app/app/jobs/downstream_job.rb
@@ -0,0 +1,11 @@
+# Enqueued by this app onto the `downstream` queue but performed by the
+# separately-instrumented downstream service, which is the only worker draining
+# that queue. In collector mode the trace context rides along on the Que job
+# (via a Que tag), so the job's trace (under the downstream app) links back to
+# the enqueue span (under this app) -- job trace propagation across two services.
+class DownstreamJob < Que::Job
+ def run(argument = nil)
+ sleep 1
+ puts "downstream performed #{argument}!"
+ end
+end
diff --git a/ruby/rails8-que/app/app/views/workers/index.html.erb b/ruby/rails8-que/app/app/views/workers/index.html.erb
index ae427ade1..3550e1b4e 100644
--- a/ruby/rails8-que/app/app/views/workers/index.html.erb
+++ b/ruby/rails8-que/app/app/views/workers/index.html.erb
@@ -11,3 +11,13 @@ mailers) record an enqueue.active_job event.
- <%= link_to label, queue_workers_path(:type => type, :worker => worker, :test => test) %>
<% end %>
+
+Queue a cross-service job
+
+Enqueues a native Que job onto the downstream queue, performed by
+the separately-instrumented downstream service. In collector mode the job's
+trace links back to the enqueue span across both apps.
+
+
+ - <%= link_to "DownstreamJob (cross-service)", cross_service_workers_path %>
+
diff --git a/ruby/rails8-que/app/config/routes.rb b/ruby/rails8-que/app/config/routes.rb
index f33e417ee..c2521424c 100644
--- a/ruby/rails8-que/app/config/routes.rb
+++ b/ruby/rails8-que/app/config/routes.rb
@@ -3,6 +3,7 @@
resources :workers do
collection do
get :queue
+ get :cross_service
end
end
diff --git a/ruby/rails8-que/app/processmon.downstream.toml b/ruby/rails8-que/app/processmon.downstream.toml
new file mode 100644
index 000000000..5b3a92acf
--- /dev/null
+++ b/ruby/rails8-que/app/processmon.downstream.toml
@@ -0,0 +1,26 @@
+[[paths_to_watch]]
+path = "/app"
+ignore = [
+ "tmp",
+ "log",
+ "node_modules",
+ "public/packs"
+]
+
+[[paths_to_watch]]
+path = "/integration"
+
+# Only the downstream service runs this worker, draining just the `downstream`
+# queue, so jobs enqueued by `app` are performed here under the downstream app
+# name. (`app`'s worker uses the default queue, so it never picks these up.)
+# No web server -- this service only runs jobs.
+[processes.que]
+command = "bundle"
+args = [
+ "exec",
+ "que",
+ "--queue-name",
+ "downstream",
+ "./config/environment.rb"
+]
+working_dir = "/app"
diff --git a/ruby/rails8-que/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-downstream b/ruby/rails8-que/commands/run-downstream
new file mode 100755
index 000000000..735a24059
--- /dev/null
+++ b/ruby/rails8-que/commands/run-downstream
@@ -0,0 +1,27 @@
+#!/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
+
+# 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-que/commands/run-worker b/ruby/rails8-que/commands/run-worker
new file mode 100755
index 000000000..e1aa38eca
--- /dev/null
+++ b/ruby/rails8-que/commands/run-worker
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /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/docker-compose.agent.yml b/ruby/rails8-que/docker-compose.agent.yml
index 6ac437c3d..58a903298 100644
--- a/ruby/rails8-que/docker-compose.agent.yml
+++ b/ruby/rails8-que/docker-compose.agent.yml
@@ -5,3 +5,9 @@ services:
app:
environment:
- APPSIGNAL_APP_NAME=ruby/rails8-que
+ 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 153d35461..41f07eab6 100644
--- a/ruby/rails8-que/docker-compose.collector.yml
+++ b/ruby/rails8-que/docker-compose.collector.yml
@@ -6,4 +6,15 @@ 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-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 f039028d5..0de128996 100644
--- a/ruby/rails8-que/docker-compose.shared.yml
+++ b/ruby/rails8-que/docker-compose.shared.yml
@@ -24,6 +24,39 @@ 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:
+ 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
+ # 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/app/controllers/workers_controller.rb b/ruby/rails8-resque/app/app/controllers/workers_controller.rb
index cc2fecb59..1bec69da4 100644
--- a/ruby/rails8-resque/app/app/controllers/workers_controller.rb
+++ b/ruby/rails8-resque/app/app/controllers/workers_controller.rb
@@ -32,6 +32,14 @@ def queue
redirect_to({ :action => :index }, :notice => "Worker queued")
end
+ # Enqueue a native Resque job onto the `downstream` queue, which only the
+ # separately instrumented downstream service drains. In collector mode this
+ # shows job trace propagation across two services.
+ def cross_service
+ Resque.enqueue(DownstreamJob, "Cross-service test")
+ redirect_to({ :action => :index }, :notice => "Cross-service job queued")
+ end
+
private
def handle_resque(worker, args)
diff --git a/ruby/rails8-resque/app/app/jobs/downstream_job.rb b/ruby/rails8-resque/app/app/jobs/downstream_job.rb
new file mode 100644
index 000000000..df3676342
--- /dev/null
+++ b/ruby/rails8-resque/app/app/jobs/downstream_job.rb
@@ -0,0 +1,13 @@
+# Enqueued by this app but performed by the separately-instrumented downstream
+# service, which is the only worker draining the `downstream` queue. In collector
+# mode the trace context rides along on the Resque payload, so the job's trace
+# (under the downstream app) links back to the enqueue span (under this app) --
+# job trace propagation across two services.
+class DownstreamJob
+ @queue = :downstream
+
+ def self.perform(argument = nil)
+ sleep 1
+ puts "downstream performed #{argument}!"
+ end
+end
diff --git a/ruby/rails8-resque/app/app/views/workers/index.html.erb b/ruby/rails8-resque/app/app/views/workers/index.html.erb
index b252c1b9c..b5bd5e985 100644
--- a/ruby/rails8-resque/app/app/views/workers/index.html.erb
+++ b/ruby/rails8-resque/app/app/views/workers/index.html.erb
@@ -10,3 +10,13 @@ Job jobs (and mailers) record an enqueue.active_job event.
<%= link_to name, queue_workers_path(:worker => worker, :test => test) %>
<% end %>
+
+Queue a cross-service job
+
+Enqueues a native Resque job onto the downstream queue, performed
+by the separately-instrumented downstream service. In collector mode the job's
+trace links back to the enqueue span across both apps.
+
+
+ - <%= link_to "DownstreamJob (cross-service)", cross_service_workers_path %>
+
diff --git a/ruby/rails8-resque/app/config/appsignal.rb b/ruby/rails8-resque/app/config/appsignal.rb
index 321ba6f3b..b48b43a5d 100644
--- a/ruby/rails8-resque/app/config/appsignal.rb
+++ b/ruby/rails8-resque/app/config/appsignal.rb
@@ -1,5 +1,5 @@
Appsignal.configure do |config|
- config.enable_rake_performance_instrumentation = true
+ config.enable_rake_performance_instrumentation = false
config.activate_if_environment(:development, :production)
end
diff --git a/ruby/rails8-resque/app/config/routes.rb b/ruby/rails8-resque/app/config/routes.rb
index f33e417ee..c2521424c 100644
--- a/ruby/rails8-resque/app/config/routes.rb
+++ b/ruby/rails8-resque/app/config/routes.rb
@@ -3,6 +3,7 @@
resources :workers do
collection do
get :queue
+ get :cross_service
end
end
diff --git a/ruby/rails8-resque/app/lib/tasks/resque.rake b/ruby/rails8-resque/app/lib/tasks/resque.rake
index a80250cc3..851547f5d 100644
--- a/ruby/rails8-resque/app/lib/tasks/resque.rake
+++ b/ruby/rails8-resque/app/lib/tasks/resque.rake
@@ -3,8 +3,10 @@ require "resque/tasks"
namespace :resque do
# `resque:work` runs this first. Loading the Rails environment makes the job
# classes available and runs the initializer that points Resque at Redis.
- # Default to working every queue so `rake resque:work` needs no arguments.
+ # Default to the `default` queue (not `*`) so this app's worker does not drain
+ # the `downstream` queue, which the separately-instrumented downstream service
+ # owns (it exports QUEUE=downstream to override this default).
task :setup => :environment do
- ENV["QUEUE"] ||= "*"
+ ENV["QUEUE"] ||= "default"
end
end
diff --git a/ruby/rails8-resque/app/processmon.downstream.toml b/ruby/rails8-resque/app/processmon.downstream.toml
new file mode 100644
index 000000000..23e246817
--- /dev/null
+++ b/ruby/rails8-resque/app/processmon.downstream.toml
@@ -0,0 +1,23 @@
+[[paths_to_watch]]
+path = "/app"
+ignore = [
+ "tmp",
+ "log",
+ "node_modules",
+ "public/packs"
+]
+
+[[paths_to_watch]]
+path = "/integration"
+
+# Only the downstream service runs this worker, draining the `downstream` queue
+# (set via QUEUE in run-downstream), so jobs enqueued by `app` are performed here
+# under the downstream app name. No web server -- this service only runs jobs.
+[processes.resque]
+command = "bundle"
+args = [
+ "exec",
+ "rake",
+ "resque:work"
+]
+working_dir = "/app"
diff --git a/ruby/rails8-resque/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-downstream b/ruby/rails8-resque/commands/run-downstream
new file mode 100755
index 000000000..11b4bba09
--- /dev/null
+++ b/ruby/rails8-resque/commands/run-downstream
@@ -0,0 +1,31 @@
+#!/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
+
+# 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
+
+echo "Starting downstream Resque worker"
+/commands/processmon processmon.downstream.toml
diff --git a/ruby/rails8-resque/commands/run-worker b/ruby/rails8-resque/commands/run-worker
new file mode 100755
index 000000000..e1aa38eca
--- /dev/null
+++ b/ruby/rails8-resque/commands/run-worker
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /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/docker-compose.agent.yml b/ruby/rails8-resque/docker-compose.agent.yml
index f5a2acd78..c57ed84a1 100644
--- a/ruby/rails8-resque/docker-compose.agent.yml
+++ b/ruby/rails8-resque/docker-compose.agent.yml
@@ -5,3 +5,9 @@ services:
app:
environment:
- APPSIGNAL_APP_NAME=ruby/rails8-resque
+ 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 df42569a5..ab3392ada 100644
--- a/ruby/rails8-resque/docker-compose.collector.yml
+++ b/ruby/rails8-resque/docker-compose.collector.yml
@@ -6,4 +6,15 @@ 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-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 42460eb90..e8a6e7dee 100644
--- a/ruby/rails8-resque/docker-compose.shared.yml
+++ b/ruby/rails8-resque/docker-compose.shared.yml
@@ -22,6 +22,39 @@ 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:
+ 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
+ # 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..e1aa38eca
--- /dev/null
+++ b/ruby/rails8-sidekiq/commands/run-worker
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+set -eu
+
+cd /app
+
+# The `app` service owns installing gems and running migrations into the shared
+# /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/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 e35609e07..60cb56c96 100644
--- a/ruby/rails8-sidekiq/docker-compose.collector.yml
+++ b/ruby/rails8-sidekiq/docker-compose.collector.yml
@@ -6,4 +6,10 @@ services:
app:
environment:
- 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
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/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