Skip to content

Cross service trace propagation#375

Merged
unflxw merged 12 commits into
mainfrom
cross-service-trace-propagation
Jul 14, 2026
Merged

Cross service trace propagation#375
unflxw merged 12 commits into
mainfrom
cross-service-trace-propagation

Conversation

@unflxw

@unflxw unflxw commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

This PR implements changes in Ruby and Python test setups to enable to test cross-service trace propagation as implemented in the AppSignal platform in general, and in appsignal/appsignal-ruby#1535 specifically.

For HTTP requests, boot up the web application's container twice, as app and as downstream. Have the server at app perform HTTP requests against the server at downstream, exercising both the trace context injection in the HTTP client's end and the extraction in the HTTP server's end.

For background jobs, boot up a different container as worker to process the jobs.

When mode=collector, use the same app name for all containers, with a different service name for each. When mode=agent, use different app names. Trace propagation only works in collector mode.

unflxw added 12 commits July 13, 2026 16:19
Collector mode adds W3C trace-context propagation, but no setup showed
a trace spanning two AppSignal apps: the HTTP client actions called
localhost (the app calling itself), and background jobs ran under a
single app name.

Make rails7-sidekiq collector-capable and run a second instance of the
same image as a distinct "downstream" app. The HTTP client actions now
call it instead of themselves, and a new DownstreamWorker runs on a
queue only the downstream service drains, alongside the existing
same-service worker. In collector mode the trace continues across both
apps over HTTP, and the job's trace links back to the enqueue span.

Exercises appsignal-ruby#1535.
Add a second instance of the image as a distinct "downstream" app that
drains the `downstream` Resque queue this app enqueues to. In collector
mode the job's trace links back to the enqueue span across both apps.
The existing same-service worker (default queue) is unchanged.

Also stop instrumenting the Resque worker as a rake task
(enable_rake_performance_instrumentation = false). Running `rake
resque:work` with that on opens a long-lived "rake" transaction that
every job reuses, so jobs were recorded under the `rake` namespace
instead of `background_job` -- in agent mode too, since rake
instrumentation was added. That also blocks the collector-mode
enqueue->perform link (the perform span is typed Server, not Consumer).

Exercises appsignal-ruby#1535.
Add a second instance of the image as a distinct "downstream" app that
drains the `downstream` Que queue this app enqueues to (via --queue-name).
In collector mode the job's trace links back to the enqueue span across
both apps. The existing same-service worker (default queue) is unchanged.

Exercises appsignal-ruby#1535.
Add a second instance of the image as a distinct "downstream" app that
drains the `downstream` SQS queue this app enqueues to. In collector
mode the trace context rides an SQS message attribute, so the job's
trace links back to the enqueue span across both apps. The existing
same-service worker (default/native queues) is unchanged.

The `downstream` queue is created in the mock SQS server by both
services (idempotent) so it exists regardless of boot order.

Exercises appsignal-ruby#1535.
Add a second instance of the image on port 4002 as a distinct
"downstream" app, and a call_downstream resource that makes an HTTP
request to it. In collector mode the Net::HTTP integration injects the
trace context and the downstream app extracts it through Webmachine's
own (non-Rack) extraction path, so the trace spans both apps. This is
the one HTTP-server extraction path not shared with the Rack
integrations.

Exercises appsignal-ruby#1535.
In collector mode both the AppSignal Ruby gem and Python package read
APPSIGNAL_SERVICE_NAME and send it as the OpenTelemetry `service.name`
resource attribute. AppSignal groups traces into a `<service>/<namespace>`
namespace based on it. When the variable is unset the service name falls
back to "unknown".

Set it explicitly to "app" for every collector setup so none of them
report an "unknown" service. The cross-service setups additionally name
their second service "downstream", which sets up the next change: sharing
a single app name across the two services in collector mode.
The cross-service setups run a second `downstream` service to exercise
trace propagation between two separately-instrumented processes. In agent
mode those two must stay separate AppSignal apps, because the agent has no
service name concept and an app is the only identity it reports.

Collector mode does have a service name, so model the pair the way it
really is: one application made of two services. Point both services at
the same APPSIGNAL_APP_NAME and let APPSIGNAL_SERVICE_NAME (app and
downstream) tell them apart. The trace then propagates across services
within a single app instead of spanning two apps.
In every Ruby setup that runs background jobs, the worker ran as a
second process inside the `app` container, next to the web server. Move
it into a dedicated `worker` container that runs the same image.

This mirrors a real web/worker deployment, and it attributes job traces
to a separate identity from the web requests. In agent mode the worker
reports to a `<name>-worker` app. In collector mode it shares the app
name and sets `APPSIGNAL_SERVICE_NAME=worker`, so its jobs group under a
`worker` service.

The `worker` service is image-only; only `app` declares `build:`. When
two services build the same image tag, `docker compose build` fails on
Docker's containerd image store with an "image already exists" error. For
the same reason the existing `downstream` services are now image-only
too.

Bare `ruby/shoryuken` is left out because it has no agent or collector
mode files to carry the worker's identity.
A DB-backed queue worker (que, solid_queue, delayed_job, good_job) polls
a table that a migration creates. run-worker only waited for gems, so the
worker could start before the app finished migrating, crash on the
missing table, and stay dead, because processmon restarts a process on
file changes but not when it exits.

Wait for the app to serve HTTP before starting the worker. The app only
starts its web server after it migrates, so a reachable app means the
schema is ready. Redis-backed workers (sidekiq, resque) did not hit this,
but the wait is harmless and keeps every run-worker identical.
The Django apps' make_request view called an external, uninstrumented
URL (appsignal.com), so nothing continued the trace. Point it at a
second, separately-instrumented instance of the same app (the
`downstream` service) via DOWNSTREAM_URL, so the trace spans both. Falls
back to the external URL when unset, so single-service runs still work.

In agent mode the downstream is a separate `-downstream` app. In
collector mode it shares the app name and sets
APPSIGNAL_SERVICE_NAME=downstream, so it is a distinct service.

Python deps install per container, not into a shared volume. The
downstream container waits until the app serves HTTP before installing.
That one signal means the app has built the appsignal dist into the
shared /integration volume (so we install from it instead of racing its
`hatch build`) and has migrated (so we never migrate from here).

`downstream` is added to ALLOWED_HOSTS, since Django rejects the
`Host: downstream:4002` header otherwise. The service is image-only so
`docker compose build` does not collide on the shared image tag. Only
the Django apps are changed, because they are the only Python setups
that make an outbound HTTP request.
The Django apps ran their Celery worker, and flask-pika ran its
RabbitMQ consumer, as a second process inside the `app` container next
to the web server. Move it into a dedicated `worker` container that runs
the same image, mirroring a real web/worker deployment.

In agent mode the worker reports to a `<name>-worker` app. In collector
mode it shares the app name and sets APPSIGNAL_SERVICE_NAME=worker, so
its work groups under a `worker` service.

The worker container waits for the app to serve HTTP before installing,
the same readiness signal the downstream service uses: it means the
appsignal dist is built in the shared /integration volume and the schema
is migrated. The service is image-only so `docker compose build` does
not collide on the shared image tag.
The downstream service runs a second web server and, for the DB-backed
setups, a queue worker (Que) that polls a table a migration creates. Like
run-worker before this, run-downstream only waited for gems, so it could
start before the app migrated, crash, and stay dead, because processmon
restarts a process on file changes but not when it exits.

Wait for the app to serve HTTP before starting downstream. The app only
starts its web server after it migrates, so a reachable app means the
schema is ready. This matches the run-worker fix.
@unflxw
unflxw requested review from lipskis and tombruijn July 14, 2026 09:02
@unflxw unflxw self-assigned this Jul 14, 2026
@backlog-helper

Copy link
Copy Markdown

Hi @unflxw,

We've found some issues with your Pull Request.

  • This Pull Request is missing labels. Please add labels to help identify types of Pull Requests. - (More info)

New issue guide | Backlog management | Rules | Feedback

@unflxw
unflxw merged commit 13dca0c into main Jul 14, 2026
76 checks passed
@unflxw
unflxw deleted the cross-service-trace-propagation branch July 14, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant