Stitching RUM to APM: End-to-End Traces in Datadog
One of the more satisfying moments in any observability demo is the click-through from a slow page load in RUM to the exact backend trace that caused it. You see a user session, you spot the request that took 2.4 seconds, you click, and you're staring at the database query that ran four times instead of once.
It feels like magic. It isn't. It's a handful of HTTP headers, a small amount of config on both ends, and a sampling decision that has to line up.
This post walks through how that link actually works in Datadog, and how to set it up whether you're running the Datadog tracer or OpenTelemetry on the backend.
What's actually happening
When the browser is about to make an HTTP request to your backend, the Datadog RUM SDK intercepts it and adds tracing headers. The backend tracer reads those headers, picks up the trace ID, and continues the trace as a child span. RUM stores the trace ID against the resource event in the session. The Datadog UI joins them at query time using that shared ID.
Two things have to be true for this to work:
- The browser and the backend speak the same trace context format (Datadog, W3C, B3, or some combination).
- The trace IDs the browser generates are accepted by the backend tracer when it continues the trace.
That's it. Everything else is plumbing.
The Datadog tracer path
This is the simplest setup because the SDKs are designed to find each other.
On the frontend, you configure RUM with allowedTracingUrls:
import { datadogRum } from '@datadog/browser-rum'
datadogRum.init({
applicationId: '<RUM_APP_ID>',
clientToken: '<RUM_CLIENT_TOKEN>',
site: 'datadoghq.com',
service: 'web-frontend',
env: 'prod',
sessionSampleRate: 100,
sessionReplaySampleRate: 20,
trackUserInteractions: true,
trackResources: true,
trackLongTasks: true,
allowedTracingUrls: [
{ match: 'https://api.example.com', propagatorTypes: ['datadog'] }
],
traceSampleRate: 100,
})
allowedTracingUrls is the important bit. By default, RUM injects no tracing headers anywhere, because doing so blindly would break any cross-origin request the backend isn't expecting. You have to tell it which origins are yours.
The propagatorTypes: ['datadog'] line tells RUM to inject the Datadog-native headers: x-datadog-trace-id, x-datadog-parent-id, x-datadog-origin, x-datadog-sampling-priority, and x-datadog-tags (the last one carries the upper 64 bits of 128-bit trace IDs plus other trace state). Valid values for propagatorTypes are datadog, tracecontext, b3, and b3multi, and you can pass multiple at once.
On the backend, install dd-trace and start the tracer. For Node:
require('dd-trace').init({
service: 'api-backend',
env: 'prod',
})
The tracer reads the x-datadog-* headers from the incoming request, continues the trace, and the link appears in the UI within a minute or two. You don't have to opt into header parsing. The default extraction style on recent dd-trace versions is Datadog,tracecontext,baggage, which picks up both Datadog-native and W3C headers without any extra config.
If your frontend and backend are on different origins, your backend will also need to allow the tracing headers through CORS:
app.use(cors({
origin: 'https://www.example.com',
allowedHeaders: [
'Content-Type',
'x-datadog-trace-id',
'x-datadog-parent-id',
'x-datadog-origin',
'x-datadog-sampling-priority',
'x-datadog-tags',
'traceparent',
'tracestate',
],
}))
I've included the W3C traceparent/tracestate pair too. If you ever flip RUM to tracecontext propagation you'll want them allowed already. Miss any of these and the headers are stripped by the preflight, the backend starts a fresh trace, and the link silently doesn't appear. It's the most common reason I see this not working.
The OpenTelemetry path
If your backend is instrumented with OpenTelemetry rather than dd-trace, the link still works, you just have to make sure both ends agree on the W3C trace context format.
On the frontend, tell RUM to use the tracecontext propagator:
datadogRum.init({
// ...same as before
allowedTracingUrls: [
{ match: 'https://api.example.com', propagatorTypes: ['tracecontext'] }
],
})
You can also pass both: propagatorTypes: ['datadog', 'tracecontext']. RUM will inject both sets of headers, which is useful if you have a mix of dd-trace and OTel-instrumented services behind the same origin.
On the backend, configure your OTel SDK to use the W3C propagator (most do by default) and make sure spans are being exported to Datadog. Two routes here.
Route 1: OTLP straight to the Datadog Agent. The Datadog Agent has accepted OTLP for traces and metrics since version 6.32 / 7.32 (logs require 6.48 / 7.48). Enable it in datadog.yaml:
otlp_config:
receiver:
protocols:
grpc:
endpoint: localhost:4317
http:
endpoint: localhost:4318
Datadog recommends binding to localhost rather than 0.0.0.0 unless you specifically need the receiver exposed off-box.
Point your OTel exporter at localhost:4317 and the Agent forwards the spans into the same APM pipeline as everything else. RUM sees the trace ID, APM sees the trace ID, and the join works.
Route 2: OpenTelemetry Collector with the Datadog exporter. If you're already running a Collector for other reasons, add the Datadog exporter:
exporters:
datadog:
api:
site: datadoghq.com
key: ${DD_API_KEY}
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [datadog]
Same outcome. The Collector ingests OTLP from your services and forwards to Datadog with the trace IDs intact.
One thing to watch with OTel: the trace ID format. OTel uses 128-bit trace IDs natively, and current dd-trace versions also default to 128-bit generation (DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED=true, plus DD_TRACE_128_BIT_TRACEID_LOGGING_ENABLED=true so the full ID lands in logs). Datadog accepts both 64- and 128-bit IDs on the wire and treats a 64-bit ID as the lower 64 bits of a zero-padded 128-bit ID, so mixed-version setups generally still correlate. If you're on something very old and the join isn't working, these are the flags to check.
Sampling has to agree
This is the bit that catches people out.
There are two RUM options at play. traceSampleRate controls the percentage of matched requests for which RUM keeps the backend trace. traceContextInjection controls which requests get headers injected at all, with two valid values:
'sampled'(the default): RUM only injects sampling headers for requests it decided to keep. Dropped requests get no headers, and the backend applies its own sampling.'all': RUM injects context on every matched request, propagating its decision (kept or dropped) so the backend honours it.
The default is fine if your backend has its own sampling and you're happy with some independent decisions. If you want strict "RUM dropped it, backend drops it too" alignment, set traceContextInjection: 'all'.
Either way, when RUM keeps a trace it sets the x-datadog-sampling-priority header (or the 01 flag in the last byte of W3C traceparent), and dd-trace honours that decision by default, so kept traces are not double-sampled on the backend. The failure mode is the opposite direction: a backend that independently sampled in a request RUM didn't keep, which produces a backend trace with no RUM session pointing at it.
What you actually see when it works
In the RUM Explorer, open a session, find a slow resource, and there's an "APM Trace" link in the side panel. Click it, you're in the APM trace view with the full backend flame graph, with the RUM resource event sitting at the top as the parent.
The other direction works too. In APM, open a trace that originated from a browser request, and you'll see a "View in RUM" link that takes you back to the session it came from. You can watch a replay of what the user was doing when they hit that 500.
For SEs, this is one of the strongest stories Datadog has. The two products are individually solid, but the join between them is what makes the platform argument land. Most prospects have RUM from one vendor and APM from another, and they cannot do this at all.
A small troubleshooting checklist
If you've set everything up and the link still isn't appearing, run through these:
- Open the browser network tab and inspect a request to your backend. Confirm the tracing headers are actually present. If they're not, your
allowedTracingUrlsdoesn't match the request URL. - If the headers are there on the request but missing after a CORS preflight, add them to
Access-Control-Allow-Headerson the backend. - Check the RUM resource event in the UI and copy the trace ID. Search for it in APM. If APM has the trace but the link doesn't appear, sampling on one side dropped it.
- If you're on OTel, confirm both sides are using the same propagator. A frontend sending
traceparentto a backend expectingx-datadog-trace-idwill produce two unconnected traces.
Where to go from here
Once the link is in place, the next thing worth setting up is the inverse: making sure trace IDs are visible in backend logs so you can pivot from a backend log line back to the user session that caused it. With dd-trace, this is DD_LOGS_INJECTION=true (or init({ logInjection: true })), and it only works with JSON-formatted logs from a supported logger like pino, bunyan, or winston. Recent versions enable this by default for supported loggers, but it's worth setting the env var explicitly so it's obvious in your config.
The official docs are at Connect RUM and Traces, worth a read if you want the full matrix of supported propagators and tracer versions.