Connecting Logs and Traces
The idea in one sentence
Section titled “The idea in one sentence”A tracer already knows which span is active while your code handles a request, so log injection is simply the tracer handing that active trace_id (and span_id) to your logging library, so every log line written during that request carries the identifiers needed to find it again.
Without it: a log line with nowhere to go
Section titled “Without it: a log line with nowhere to go”2026-07-08T10:14:02Z ERROR Failed to charge card for order 48213This line tells you something failed — it does not tell you which downstream call was slow, which service further down the chain actually produced the error, or what else happened during the same request. Without an identifier stamped onto both the log and the trace, correlating them means guessing by timestamp and hoping nothing else touched that host in the same second.
The general mechanism: a log integration reads the active span
Section titled “The general mechanism: a log integration reads the active span”Every tracer keeps the currently active span in some form of request-scoped context — thread-local in a threaded runtime, async-local (or continuation-local) in an event-loop runtime. A log injection integration is a small hook into your logging library — a formatter, a processor, or a patched logger method — that runs on every log record and asks the tracer “what span is active right now?” If there is one, it copies that span’s trace_id and span_id onto the record before it is emitted. Enable it once, and every log statement already in the codebase benefits, without being touched individually.
One concrete example: Node.js
Section titled “One concrete example: Node.js”require('dd-trace').init({ logInjection: true});That option is the entire integration step for a Node.js service using dd-trace. Once it is set, any log emitted through a supported logging library (pino, winston, bunyan) automatically gets the active trace context attached to its structured output — no change to any individual logger.info(...) or logger.error(...) call anywhere in the app.
Proof: an unknown option key is a silent no-op, not an error
Section titled “Proof: an unknown option key is a silent no-op, not an error”This matters because a plain JavaScript object literal never rejects a key it doesn’t recognize — dd-trace just never looks at it. Installed [email protected] in a scratch directory (matching this course’s pinned version) and dumped the tracer’s real resolved config three ways — t._tracer._config.logInjection is dd-trace’s own internal, already-merged setting, and DD_TRACE_STARTUP_LOGS (default true, per supported-configurations.json) additionally prints the same picture unprompted at init() time as a DATADOG TRACER CONFIGURATION - log line, so you don’t even need to know about the private field to see it:
$ node -e "const t=require('dd-trace').init({logInjection:false,service:'probe'}); console.log(t._tracer._config.logInjection)"DATADOG TRACER CONFIGURATION - {...,"log_injection_enabled":false,...}camelCase logInjection:false -> false # did what was asked
$ node -e "const t=require('dd-trace').init({log_injection:false,service:'probe'}); console.log(t._tracer._config.logInjection)"DATADOG TRACER CONFIGURATION - {...,"log_injection_enabled":true,...}snake_case log_injection:false -> true # silently ignored -- still the untouched default
$ node -e "const t=require('dd-trace').init({service:'probe'}); console.log(t._tracer._config.logInjection)"no logInjection key at all -> true # true is the real default either wayBecause the real default is already true, log_injection: true in the old version of this example “looked like it worked” by pure coincidence — but anyone who copies the same misspelled pattern to write log_injection: false, intending to turn injection off (a real, common need for a service that must not leak trace_id into a log destination shared outside the org), silently gets no such thing. npm run verify’s --options mode exists specifically to catch this class of hallucinated key before it ships: it checks every top-level key in a collected tracer.init({...}) fence against the real, installed .d.ts — the fence above (logInjection: true) passes; log_injection: true would not have been collected as valid in the first place.
Trace context propagation: how two services agree it’s the same trace
Section titled “Trace context propagation: how two services agree it’s the same trace”Log injection answers “which trace is this log line part of.” A separate, related question is how the trace itself stays one trace across a network call between two services — and the answer is a small set of HTTP headers a tracer injects on the way out and extracts on the way in. Ran a dd-trace-instrumented http.get() against a local HTTP server that only prints the headers it received — nothing left this machine, and DD_TRACE_AGENT_URL pointed at a closed local port so there was no live agent involved either:
$ DD_TRACE_PROPAGATION_STYLE=datadog,tracecontext node propagation.js--- headers received by downstream server ---x-datadog-trace-id: 5586879601662099183x-datadog-parent-id: 703201510884566073x-datadog-sampling-priority: 1x-datadog-tags: _dd.p.tid=6abbb93000000000,_dd.p.dm=-0traceparent: 00-6abbb930000000004d889565abf1feef-09c2460c65cae439-01tracestate: dd=t.dm:-0;t.tid:6abbb93000000000;s:1;p:09c2460c65cae439x-datadog-trace-id and x-datadog-parent-id are Datadog’s own native propagation format; traceparent/tracestate are the W3C Trace Context standard. Both rode on the same outgoing request because DD_TRACE_PROPAGATION_STYLE was set to datadog,tracecontext — a tracer can inject (and accept) more than one format at once, which is exactly what makes it possible to sit between a legacy Datadog-only service and a newer OpenTelemetry-instrumented one.
DD_TRACE_PROPAGATION_STYLE’s real accepted values, read straight from the installed package’s parsers.js (VALID_PROPAGATION_STYLES) — datadog, tracecontext, b3, b3 single header (an alias for b3 from major version 6 onward), b3multi, baggage, none. The Node.js library-config docs (docs.datadoghq.com/tracing/trace_collection/library_config/nodejs/) confirm the default: datadog,tracecontext,baggage. Switching styles changes the wire format completely — same trace, different headers:
$ DD_TRACE_PROPAGATION_STYLE=b3multi node propagation.jsx-b3-traceid: 6abbb93e00000000041b1cd0943a09acx-b3-spanid: 2a31fba7df01f8bdx-b3-sampled: 1x-b3-parentspanid: 041b1cd0943a09ac
$ DD_TRACE_PROPAGATION_STYLE=b3 node propagation.jsb3: 6abbb93e00000000256681dd9f945d47-24d519d1a7e5f9c8-1-256681dd9f945d47
$ DD_TRACE_PROPAGATION_STYLE=none node propagation.js--- headers received by downstream server --- # (nothing -- no propagation headers injected at all)That last run is the practical trap: if two services in a request path have DD_TRACE_PROPAGATION_STYLE sets with zero format in common (one only injects b3multi, the other only extracts datadog), the trace silently splits into two disconnected traces at that hop — no error anywhere, just a service map with a gap in it. This is the network-level twin of the logInjection bug above: a mismatch that produces no exception, only missing data.
The dd.* convention: identity riding along in the log line
Section titled “The dd.* convention: identity riding along in the log line”{ "level": "error", "message": "Failed to charge card for order 48213", "dd": { "trace_id": "6746734253672554013", "span_id": "3993638273767594310", "env": "production", "service": "checkout-service", "version": "2.4.1" }}Datadog tracers converge on a dd.* namespace for these injected fields. dd.trace_id and dd.span_id are the pivot keys — they name the exact trace and the exact span that was executing when this line was written. dd.env, dd.service, and dd.version are the unified service tagging triple riding along too, which means this single log line carries enough identity to also be found through the structured logging and facets built out in the Log Management module — it is not just correlatable to a trace, it is a first-class faceted log event in its own right. Ran the equivalent Node.js code for real — winston configured with logInjection: true and a JSON formatter, one logger.error() call inside an active span — and it produces exactly this shape without any manual field-setting:
$ node logcorrelate.js{"dd":{"env":"production","service":"checkout-service","span_id":"5160495945473134754","trace_id":"6abbb93800000000479dc3b88ec510a2","version":"2.4.1"},"level":"error","message":"Failed to charge card for order 48213"}The Python equivalent: DD_LOGS_INJECTION and ddtrace-run
Section titled “The Python equivalent: DD_LOGS_INJECTION and ddtrace-run”ddtrace (Python) uses an environment variable rather than a programmatic key for this: DD_LOGS_INJECTION. Installed the pinned ddtrace==4.15.2 in a scratch venv and introspected the real, live config object instead of guessing from memory — worth noting the attribute name changed between major versions of the package itself, which is exactly the kind of thing that goes stale in an AI model’s training data:
$ python -c "from ddtrace import config; print(config._logs_injection)"True # real default, this pinned version
$ DD_LOGS_INJECTION=false python -c "from ddtrace import config; print(config._logs_injection)"False
$ DD_LOGS_INJECTION=true python -c "from ddtrace import config; print(config._logs_injection)"TrueSetting the config attribute in code after import is not the documented activation path — the real mechanism patches the standard logging module’s record formatting, and that patch is applied through ddtrace-run (or ddtrace.patch_all()), not by touching config directly. Ran a plain logging-based script with a format string referencing %(dd.trace_id)s/%(dd.span_id)s/%(dd.service)s/%(dd.env)s/%(dd.version)s under ddtrace-run with DD_LOGS_INJECTION=true:
$ DD_LOGS_INJECTION=true DD_SERVICE=checkout-service DD_ENV=production DD_VERSION=2.4.1 \ ddtrace-run python logcorrelate.py2026-09-29 20:18:51,330 ERROR [__main__] [dd.service=checkout-service dd.env=productiondd.version=2.4.1 dd.trace_id=6abbbabb000000005b560a95a242be0fdd.span_id=8465665182175932103] - Failed to charge card for order 48213Same idea as Node’s logInjection: true, same dd.* field names, different activation mechanism: an env var plus the ddtrace-run wrapper (or ddtrace.patch_all() in-process), rather than a key inside tracer.init(). ddtrace-run --help confirms the binary is real and documented, not a hallucinated entry point.
The payoff: one click, not a manual search
Section titled “The payoff: one click, not a manual search”Before log injection, an error-severity log line sits by itself, with no easy way to see the request around it — what called this service, what it called next, where the time went. After log injection, because the log carries dd.trace_id, Datadog can put a “view trace” pivot directly on that log line in the Logs Explorer — one click, and you land on the exact distributed trace, with every span across every service that took part in that request, spans that never looked at the log at all.
flowchart LR A[Incoming request] --> B[Tracer starts span] B --> C["App code calls logger.error(...)"] C --> D["Log injection hook reads active span"] D --> E["Log emitted with dd.trace_id, dd.span_id, dd.env/service/version"] E --> F[Datadog Log Management] F -->|click dd.trace_id| G[APM distributed trace view]
Reading AI-generated code
Section titled “Reading AI-generated code”🐛 Spot the bug
// AI comment: "Enable log-trace correlation for our checkout service"require('dd-trace').init({service: 'checkout-service',env: 'production',log_injection: true});
// elsewhere, for a service that must NOT leak trace_id into a// log destination shared outside the org:// require('dd-trace').init({ log_injection: false });Show the answer
This compiles and runs without a single warning — a plain object literal never rejects an unrecognized key. The bug, proven above with a real tracer config dump: log_injection is not a dd-trace option; the real key is logInjection (camelCase, DD_LOGS_INJECTION at the env-var level). Because the real default for log injection is already true, the first snippet “works” purely by accident — but the second snippet, which exists specifically to turn injection off for a compliance reason, does nothing at all: the service keeps injecting trace_id/span_id into every log line, silently, exactly the outcome the AI-generated comment says it was trying to prevent. The fix is logInjection: true / logInjection: false — and npm run verify’s --options mode in this course’s own harness would have caught this by checking the key against the pinned package’s real .d.ts before it ever shipped.
Mental Model
Section titled “Mental Model”Think of log injection and trace propagation as the same idea applied at two different scopes. Log injection is a tracer handing its currently active span’s identifiers sideways, to a logging library running in the very same process. Propagation headers are a tracer handing that same trace’s identifiers forward, over the network, to the next process. Both are “stamp the active trace context onto something that isn’t the trace itself” — one stamps a log record, the other stamps an HTTP request — and both fail exactly the same way when misconfigured: not with an exception, but with a gap. A log with no dd.trace_id just looks like an ordinary log. A request with no (or mismatched) propagation headers just starts a brand-new trace instead of continuing the old one. Neither failure throws; both only show up as “the pivot I expected isn’t there.”
Trade-offs
Section titled “Trade-offs”logInjection: true(orDD_LOGS_INJECTION=true) vs. leaving it off — turning it on is one flag and costs nothing at the network level (no data leaves the process any differently), but it does meantrace_id/span_idvalues now ride inside every log line, which matters if those logs are ever shipped somewhere outside your own Datadog org’s control.- Multiple
DD_TRACE_PROPAGATION_STYLEformats at once vs. just one — injectingdatadog,tracecontexttogether (proven above) makes a service interoperate with both legacy Datadog-only and W3C-standard tracers, at the cost of slightly larger request headers and one more thing to keep consistent across every service in the request path. - Debugging via a real config dump (
DD_TRACE_STARTUP_LOGS,_configintrospection) vs. trusting the code you wrote — reading the tracer’s own resolved config is slower than assuming yourinit()call did what you meant, but it is the only way that actually catches a silently-ignored key likelog_injectionbefore it reaches production.
Common Misconceptions
Section titled “Common Misconceptions”- “A misspelled
tracer.init()option throws or warns.” No — proven above:log_injection: true/log_injection: falseboth run cleanly with no error, no warning, nothing in stderr. JavaScript object literals never validate their keys against anything. - “
log_injection: truein this lesson’s old example was correct because it worked.” No — proven above: it “worked” only becausetrueis already the untouched default. The exact same typo withlog_injection: false, intending to turn injection off, is silently ineffective. - “Trace propagation headers are one fixed format.” No — proven above:
DD_TRACE_PROPAGATION_STYLEcan select Datadog-native headers, W3Ctraceparent/tracestate, B3 (single or multi-header), ornone, and a tracer can inject several formats on the same request simultaneously. - “If propagation is misconfigured, you’ll see an error.” No — proven above: setting
DD_TRACE_PROPAGATION_STYLE=noneproduces a completely clean run with zero propagation headers sent; the downstream service just starts a new, disconnected trace. Nothing throws.