Skip to main content
In this guide you’ll learn how to fix a common tracing problem: two services (payment and charge) producing separate traces for the same user request. The root cause is that the OpenTelemetry context (trace id, span, trace state) is not being propagated across the HTTP boundary. The solution is to inject the current context as headers on the client side and to extract and activate that context on the server side. Overview
  • Client (payment service): inject the current OpenTelemetry context into outgoing HTTP headers.
  • Server (charge service): extract the context from incoming headers, attach it for the request lifecycle, and detach it in teardown.
We inject the context into an HTTP carrier (headers) with inject(...) and retrieve it on the server with extract(...). Both utilities are available from opentelemetry.propagate.
Quick reference
ActionFunctionTypical use
Inject context into outgoing carrierinject(headers)Client: add propagation fields to requests headers
Extract context from incoming carrierextract(request.headers)Server: read propagation fields from incoming request

Payment service (client) — inject context into request headers

Goals
  • Configure the tracer provider and exporter for the payment service.
  • Create a client span for the outgoing HTTP request.
  • Call inject(headers) before sending the request so propagation fields are added to the HTTP headers.
  • Pass headers to requests.get(..., headers=headers).
Example payment service (simplified):
Key points
  • inject(headers) mutates your headers dictionary to include propagation fields (traceparent, tracestate, etc.).
  • Use headers=headers when calling requests.get() so the downstream service receives the context.

Charge service (server) — extract and attach context from incoming headers

Goals
  • Configure tracer provider and exporter for the charge service.
  • Extract the incoming context from request headers and attach it so the propagated trace becomes active for the request handler.
  • Save the returned token to request.environ and detach it in teardown_request to restore the previous context.
Important functions: opentelemetry.propagate.extract, opentelemetry.context.attach, and opentelemetry.context.detach. Example charge service:
Flask passes an exception argument to teardown_request. The teardown function must accept that parameter (for example def teardown_request_func(exc):) even if you don’t use it, otherwise Flask will raise an error.

Run and verify

  1. Start the charge service (server): python charge.py
  2. Start the payment service (client) and trigger the request: python payment.py
  3. Observe the console output or your tracing backend. With the Console exporter you will see both client and server spans logged; with OTLP -> Jaeger you will see a single distributed trace containing both services.
Expected behavior
  • Both client and server spans should share the same trace_id.
  • The server span’s parent_id should match the client span’s span_id, indicating correct parent/child relationships.
Example of a client span (trimmed):
Corresponding server span (trimmed):
Notice:
  • Shared trace_id confirms both spans belong to the same distributed trace.
  • parent_id on the server span matches the client span’s span_id, confirming the correct parent-child relationship.
If you use OTLP -> Jaeger or another backend, you should see a single trace containing both services (payment and charge). Example screenshot of Jaeger UI showing both services inside the same trace:
The image shows a Jaeger UI interface displaying a trace for a "payment service," detailing operations like "Starting Payment" and "Charge Account," along with timing information for each step.
With these propagation changes the two services are connected in the same trace, enabling end-to-end visibility across the request path. Links and references

Watch Video