Closing the Visibility Gap: Manual Trace Propagation in Asynchronous Workflows
Stop losing visibility in asynchronous workflows. Learn how to manually propagate trace contexts across message queues to maintain a single, continuous flame graph in Datadog APM.
20 Nov 2025, 04:47 UTC

The 'Broken Trace' Problem
Automatic instrumentation is a powerful feature of Datadog APM, but it has a blind spot: asynchronous boundaries. When a request moves from a synchronous API call to a message queue like Kafka or RabbitMQ, the trace often ends abruptly. The producer sends a message, and the consumer processes it, but in the Datadog UI, these appear as two unrelated events rather than a single distributed transaction.
To fix this, you must manually propagate the trace context. The goal is to ensure the trace_id from the producer is injected into the message headers and extracted by the consumer, allowing Datadog to stitch the spans back together into a single flame graph.
Understanding Context Propagation
Distributed tracing relies on a Trace Context—a set of identifiers (Trace ID, Span ID, and Parent ID) that define the request's lineage. While Datadog's libraries automatically handle this for HTTP requests via headers, they cannot automatically "reach inside" a custom message payload or a specific queue implementation to pass these IDs.
When you move to an asynchronous model, you are responsible for two actions:
- Injection: Adding the current trace context to the message metadata before it is published.
- Extraction: Reading that metadata at the destination and starting a new span that references the original trace as its parent.
Implementation: Manual Span Wrapping
To resolve the gap, you can use the Datadog SDK to create custom spans. This is particularly useful for long-running background jobs where you need to measure the latency of specific internal logic that isn't covered by a framework.
Example: Python Producer and Consumer
In this scenario, we use the ddtrace library to manually link a producer and a consumer. This example assumes the Datadog Agent is running as a sidecar or daemon on the host.
Producer Side (Injecting Context):
from ddtrace import tracer
with tracer.trace("message.publish", resource="order-service") as span:
# Create a dictionary to hold the propagation headers
propagation_headers = {}
span.inject(propagation_headers)
# Send the headers along with your message payload
message = {"order_id": 123, "trace_context": propagation_headers}
queue.publish(message)
Consumer Side (Extracting Context):
from ddtrace import tracer
# Extract the context from the message headers
context = tracer.extract(message['trace_context'])
# Start a new span using the extracted context as the parent
with tracer.trace("message.process", resource="shipping-service", child_of=context) as span:
span.set_tag("order.id", message['order_id'])
# Perform business logic here
process_shipping(message['order_id'])
Execution Details
- Permissions: The application process must have network access to the Datadog Agent (typically port 8126).
- Placeholders: Replace
queue.publishandprocess_shippingwith your specific messaging client and business logic. - Risk: If
span.close()is not called (or thewithblock is not used), the span may not be flushed to the agent, resulting in missing data.
Trade-offs and Performance Constraints
Manual instrumentation provides precision, but it introduces three primary risks:
| Constraint | Impact | Mitigation |
|---|---|---|
| Cardinality | Adding unique IDs (like user_id) as tags can explode the index size and increase costs. |
Use tags for categories (e.g., region) and use logs for high-cardinality IDs. |
| Overhead | Wrapping every single function in a custom span adds CPU and memory latency. | Only wrap high-level boundaries or expensive operations. |
| Sampling | If the producer is sampled out but the consumer is sampled in, the trace may appear fragmented. | Configure consistent sampling rates across the pipeline. |
Verifying the Trace
To confirm the implementation is working, trigger a request through the producer and navigate to the APM > Traces explorer in Datadog.
- Search for the
resourcename used in the consumer (e.g.,message.process). - Open the trace and verify that the
order-service(producer) andshipping-service(consumer) appear in the same flame graph. - Check the Tags tab of the consumer span to ensure your custom tags (like
order.id) are present and searchable.
If the traces appear as two separate entries, verify that the trace_context dictionary is being passed correctly through your message broker without being stripped by a middleware layer.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.