Skip to content

Export propagation tags in OTLP trace export - #12985

Open
anna-git wants to merge 7 commits into
masterfrom
anna.yafi/otlp-export-propagation-tags
Open

anna-git wants to merge 7 commits into
masterfrom
anna.yafi/otlp-export-propagation-tags

Conversation

@anna-git

@anna-git anna-git commented Oct 21, 2026 •

Copy link
Copy Markdown

What Does This Do

Exports the span propagation tags (_dd.p.dm, _dd.p.ksr, ...) and baggage-as-tags items as string attributes in OTLP trace export (protobuf and JSON), mirroring the Datadog mappers:

  • OtlpTraceProto / OtlpTraceJson MetaWriters now write Metadata.getBaggage(), which was computed but never serialized. Span tags, and the attributes the writers emit directly (resource.name, operation.name, thread.name, _sampling_priority_v1, ...), win on a key clash, so OTLP attribute keys stay unique.
  • Propagation tags (ROOT_PROPAGATION_TAGS, read from the local root's context) are written once per trace in the payload, on the span the collector already selects for sampling tags (first exported span of a trace in protobuf, last in JSON). This covers partial flushes whose chunk has no local root span; other spans don't carry _dd.p.*. The Agent takes _dd.p.dm from the first span in a chunk that has it, so the carrier doesn't need to be the root.
  • _dd.p.tid is intentionally not exported: the OTLP traceId already carries all 128 bits, and the Agent's OTLP receiver derives the upper 64 bits from it, so the tag would be redundant (Java generates 128-bit trace ids by default, so every root span would otherwise carry it). Same as the .NET change.

Motivation

With DD_TRACE_OTEL_EXPORTER=otlp, remote and adaptive sampling rules are applied (priority, _dd.rule_psr are exported), but the sampling decision maker _dd.p.dm never was. The Agent then falls back to a default decision maker, so the backend doesn't count the traffic as rule/adaptive-sampled and adaptive sampling can't converge for the service.

This complements DataDog/datadog-agent#58471, which preserves _dd.p.dm from OTLP span attributes (or t.dm from a tracestate dd= member). The .NET equivalent is DataDog/dd-trace-dotnet#9498.

Additional Notes

  • Validated with a local DDOT collector and remote/adaptive sampling rules: before, traces reached intake with _dd.p.dm:-4; with this change the exported trace is expected to carry the rule's decision maker (-11 remote user rule / -12 remote adaptive rule), which the Agent keeps with [trace-agent] Preserve tracer sampling decision maker in OTLP receiver datadog-agent#58471. The end-to-end run with this exact build is still to do.
  • Unit tests in OtlpTraceProtoTest and OtlpTraceJsonCollectorTest: propagationTagsAreWrittenOncePerTraceOnTheSpanSelectedForSamplingTags, partialFlushWithoutTheLocalRootStillCarriesPropagationTags and propagationTagsAreWrittenOnceForEachTraceInThePayload check exactly one copy of _dd.p.dm/_dd.p.ksr per trace, on the selected span, no _dd.p.tid, and baggage on every span; baggageDoesNotDuplicateDirectlyWrittenAttributes in both checks that baggage items named like directly emitted attributes don't duplicate them. :dd-trace-core:spotlessCheck couldn't run locally because the Eclipse p2 site used by the Groovy formatter was down; changed files were checked with google-java-format 1.36.1.
  • Follow-ups, not in this PR:
    • Locally started traces have no dd= tracestate member, so the OTLP trace_state doesn't carry t.dm.

Contributor Checklist

Jira ticket: N/A

🤖 Generated with Claude Code

The OTLP trace writers dropped the Metadata baggage map, which carries the
propagation tags (_dd.p.dm, _dd.p.ksr, _dd.p.tid...) and baggage items
injected as tags. Write it like the Datadog mappers do, with propagation
tags on the local root span only, so the sampling decision maker reaches
the Agent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@anna-git anna-git added type: bug fix Bug fix comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM labels Oct 21, 2026
@datadog-datadog-prod-us1

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 14.00 s 13.94 s [-0.3%; +1.0%] (no difference)
startup:insecure-bank:tracing:Agent 12.89 s 12.97 s [-1.4%; +0.1%] (no difference)
startup:petclinic:appsec:Agent 17.04 s 17.02 s [-0.8%; +1.0%] (no difference)
startup:petclinic:iast:Agent 16.83 s 16.97 s [-1.5%; -0.1%] (maybe better)
startup:petclinic:profiling:Agent 16.66 s 16.68 s [-1.2%; +0.9%] (no difference)
startup:petclinic:sca:Agent 17.01 s 16.27 s [+0.3%; +8.8%] (maybe worse)
startup:petclinic:tracing:Agent 16.07 s 15.83 s [-2.9%; +5.9%] (no difference)

Commit: ee1fa5bb · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

anna-git and others added 2 commits October 22, 2026 13:54
The OTLP traceId already carries all 128 bits, so the tag is redundant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A public Tags constant must be registered in the tag registry; the key is only needed by the OTLP writers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Both encoders can emit duplicate attribute keys when baggage collides with directly emitted span fields or metadata.

1 open issue
What changed in this PR

This PR adds propagation tags and baggage-as-tags to Java’s OTLP trace export, allowing downstream Agent processing to retain sampling decision metadata.

Changes:

  • Serialize baggage and propagation tags in protobuf and JSON, with collision checks against span tags.
  • Export propagation tags only on local roots and omit redundant _dd.p.tid.
  • Add tests for sampling tags, inherited baggage, and root-only propagation tags.
File Description
dd-trace-core/​src/​test/​java/​datadog/​trace/​core/​otlp/​trace/​OtlpTraceProtoTest.java Adds protobuf export assertions and attribute parsing helpers.
dd-trace-core/​src/​test/​java/​datadog/​trace/​core/​otlp/​trace/​OtlpTraceJsonCollectorTest.java Adds JSON export assertions and span lookup helper.
dd-trace-core/​src/​main/​java/​datadog/​trace/​core/​otlp/​trace/​OtlpTraceProto.java Serializes baggage and root propagation tags into protobuf attributes.
dd-trace-core/​src/​main/​java/​datadog/​trace/​core/​otlp/​trace/​OtlpTraceJson.java Applies equivalent attribute serialization to JSON export.

Review effort: Balanced

// which also keeps OTLP attribute keys unique. _dd.p.tid is skipped: the OTLP traceId already
// carries the upper 64 bits of the trace id.
for (Map.Entry<String, String> entry : metadata.getBaggage().entrySet()) {
if (TRACE_ID_HIGH_TAG.equals(entry.getKey()) || tags.containsKey(entry.getKey())) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. By default OTLP mode doesn't inject baggage as tags (writer.baggage.inject defaults to false when the OTLP exporter is on), so only _dd.p.* reaches this loop. With it enabled, a raw (non-W3C) baggage key could still collide with attributes the writer emits directly. Fixed in a0096d2: baggage now also skips those keys in both encoders, with regression tests asserting each key appears once with the span's value. 6d518d3 then moves those keys and the skip set into one shared OtlpTraceAttributes class so the two writers can't drift apart.

@anna-git
anna-git marked this pull request as ready for review October 23, 2026 14:49
@anna-git
anna-git requested a review from a team as a code owner October 23, 2026 14:49
@anna-git
anna-git requested review from khanayan123 and removed request for a team October 23, 2026 14:49
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review 🔄 Running since 2026-10-23T14:49:13.697252Z 6d518d3 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@anna-git
anna-git requested a review from mcculls October 23, 2026 14:57

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bits Code Review: FAIL

Baggage collision filtering misses direction-dependent OTLP tag renames. With baggage injection and OTel semantics enabled, both encoders can emit conflicting server.port attributes instead of preserving the span's value.

Open Bits AI session

🤖 Bits Code Review · Commit 6d518d3

// baggage items injected as tags, as in the Datadog mappers. Span tags and the attributes
// written directly above win on a key clash, which keeps OTLP attribute keys unique.
for (Map.Entry<String, String> entry : metadata.getBaggage().entrySet()) {
if (SKIPPED_BAGGAGE_KEYS.contains(entry.getKey()) || tags.containsKey(entry.getKey())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Deduplicate baggage against direction-dependent export names

With baggage injection and OTel semantics enabled, a client span carrying peer.port=443 and raw baggage server.port="80" exports two server.port attributes. TagMap.containsKey("server.port") checks the distinct server-port tag, while writeSpanTag renames peer.port to server.port for client spans. The span's value should win, but consumers instead receive conflicting duplicate keys. Both protobuf and JSON writers need deduplication against actual exported names, including direction-dependent renames, with regression coverage.

React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it

What each reaction means
  • 👍 Useful: correct, and you're fixing it or will.
  • 👎 Wrong: the finding is incorrect, for example the bug can't happen or the code was misread.
  • 😕 Unclear: you can't tell what it means or why it matters.
  • 👀 Right, not acting: correct, but intended, out of scope for this PR, a known risk, or too minor.

🤖 Bits Code Review · Open Bits AI session

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposed patch from Codex, posted at Stuart's request.

Move the existing direction calculation above the baggage loop and resolve direction-dependent baggage names before checking for a matching span tag. containsKey() has no direction-aware overload; directionalKeyOf() plus getEntry(long) covers these aliases. Gate that extra lookup on OTel semantics, matching writeSpanTag().

diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceProto.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceProto.java
--- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceProto.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceProto.java
@@
       TagMap tags = metadata.getTags();
+      // The direction is computed once per span; boxing -1..2 hits the Integer cache.
+      Integer direction = KnownTagCodec.directionOf(tags);
+      boolean otelSemantics = Config.get().isTraceOtelSemanticsEnabled();
@@
       for (Map.Entry<String, String> entry : metadata.getBaggage().entrySet()) {
-        if (SKIPPED_BAGGAGE_KEYS.contains(entry.getKey()) || tags.containsKey(entry.getKey())) {
+        long directionalTagId =
+            otelSemantics ? KnownTagCodec.directionalKeyOf(entry.getKey(), direction) : 0;
+        if (SKIPPED_BAGGAGE_KEYS.contains(entry.getKey())
+            || tags.containsKey(entry.getKey())
+            || (directionalTagId != 0 && tags.getEntry(directionalTagId) != null)) {
           continue;
         }
@@
-      // The direction is computed once per span; boxing -1..2 hits the Integer cache.
-      Integer direction = KnownTagCodec.directionOf(tags);
       tags.forEach(buf, direction, OtlpTraceProto::writeSpanTag);
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceJson.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceJson.java
--- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceJson.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceJson.java
@@
       TagMap tags = metadata.getTags();
+      // The direction is computed once per span; boxing -1..2 hits the Integer cache.
+      Integer direction = KnownTagCodec.directionOf(tags);
+      boolean otelSemantics = Config.get().isTraceOtelSemanticsEnabled();
@@
       for (Map.Entry<String, String> entry : metadata.getBaggage().entrySet()) {
-        if (SKIPPED_BAGGAGE_KEYS.contains(entry.getKey()) || tags.containsKey(entry.getKey())) {
+        long directionalTagId =
+            otelSemantics ? KnownTagCodec.directionalKeyOf(entry.getKey(), direction) : 0;
+        if (SKIPPED_BAGGAGE_KEYS.contains(entry.getKey())
+            || tags.containsKey(entry.getKey())
+            || (directionalTagId != 0 && tags.getEntry(directionalTagId) != null)) {
           continue;
         }
@@
-      // The direction is computed once per span; boxing -1..2 hits the Integer cache.
-      Integer direction = KnownTagCodec.directionOf(tags);
       tags.forEach(writer, direction, OtlpTraceJson::writeSpanTag);

This retains the current collision guards and adds the missing direction-dependent check. Suggested regression coverage in both encodings: with baggage injection and OTel semantics enabled, a client span with peer.port=443 and peer.hostname=example.com, plus conflicting baggage server.port=80 and server.address=other.example, should export each key exactly once with the span's value. Assert against the raw attribute list so a map does not hide duplicates. Also cover inbound aliases and OTel semantics disabled.

This is a proposed patch based on source inspection; I have not applied or run it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied @mcculls's patch in 6dfd8eedde ("Don't let baggage duplicate direction-dependent OTLP attribute names"): under OTel semantics, each baggage key is now resolved to its direction-specific tag (KnownTagCodec.directionalKeyOf) and skipped if the span has that tag, so e.g. peer.hostname/peer.port on a client span win over baggage server.address/server.port. Added regression tests (outbound and inbound aliases, plus OTel semantics disabled) asserting each key appears once in the raw attribute list with the span's value. Note this only applies with baggage-as-tags enabled, which is off by default in OTLP mode.

Comment on lines 243 to 257
TagMap tags = metadata.getTags();

// Baggage carries the propagation tags (e.g. the _dd.p.dm sampling decision maker) and any
// baggage items injected as tags, as in the Datadog mappers. Span tags and the attributes
// written directly above win on a key clash, which keeps OTLP attribute keys unique.
for (Map.Entry<String, String> entry : metadata.getBaggage().entrySet()) {
if (SKIPPED_BAGGAGE_KEYS.contains(entry.getKey()) || tags.containsKey(entry.getKey())) {
continue;
}
writeAttribute(writer, STRING_ATTRIBUTE, entry.getKey(), entry.getValue());
}

// The direction is computed once per span; boxing -1..2 hits the Integer cache.
Integer direction = KnownTagCodec.directionOf(tags);
tags.forEach(writer, direction, OtlpTraceJson::writeSpanTag);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposed patch from Codex, posted at Stuart's request. This is the JSON counterpart of the protobuf-thread suggestion.

Compute direction before the baggage loop and check direction-dependent aliases when OTel semantics are enabled. For example, client span tags peer.port and peer.hostname export as server.port and server.address; baggage under those exported names must not create duplicate attributes.

Suggested change
TagMap tags = metadata.getTags();
// Baggage carries the propagation tags (e.g. the _dd.p.dm sampling decision maker) and any
// baggage items injected as tags, as in the Datadog mappers. Span tags and the attributes
// written directly above win on a key clash, which keeps OTLP attribute keys unique.
for (Map.Entry<String, String> entry : metadata.getBaggage().entrySet()) {
if (SKIPPED_BAGGAGE_KEYS.contains(entry.getKey()) || tags.containsKey(entry.getKey())) {
continue;
}
writeAttribute(writer, STRING_ATTRIBUTE, entry.getKey(), entry.getValue());
}
// The direction is computed once per span; boxing -1..2 hits the Integer cache.
Integer direction = KnownTagCodec.directionOf(tags);
tags.forEach(writer, direction, OtlpTraceJson::writeSpanTag);
TagMap tags = metadata.getTags();
// The direction is computed once per span; boxing -1..2 hits the Integer cache.
Integer direction = KnownTagCodec.directionOf(tags);
boolean otelSemantics = Config.get().isTraceOtelSemanticsEnabled();
// Baggage carries the propagation tags (e.g. the _dd.p.dm sampling decision maker) and any
// baggage items injected as tags, as in the Datadog mappers. Span tags and the attributes
// written directly above win on a key clash, which keeps OTLP attribute keys unique.
for (Map.Entry<String, String> entry : metadata.getBaggage().entrySet()) {
long directionalTagId =
otelSemantics ? KnownTagCodec.directionalKeyOf(entry.getKey(), direction) : 0;
if (SKIPPED_BAGGAGE_KEYS.contains(entry.getKey())
|| tags.containsKey(entry.getKey())
|| (directionalTagId != 0 && tags.getEntry(directionalTagId) != null)) {
continue;
}
writeAttribute(writer, STRING_ATTRIBUTE, entry.getKey(), entry.getValue());
}
tags.forEach(writer, direction, OtlpTraceJson::writeSpanTag);

Suggested regression coverage: enable baggage injection and OTel semantics, add conflicting baggage named server.port and server.address to a client span, and assert each key occurs once with the span's value in the raw JSON attribute list. Also cover inbound aliases and OTel semantics disabled.

This is a proposed patch based on source inspection; I have not applied or run it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 6dfd8eedde, same change as in the protobuf encoder: direction-dependent aliases are checked before writing baggage under OTel semantics. JSON tests cover client peer.hostname vs baggage server.address, server http.hostname / network.client.ip, and OTel semantics disabled (where both keys are kept).

Comment on lines +149 to +152
// Like the Datadog mappers, only serialize propagation tags (_dd.p.dm, _dd.p.ksr...) once per
// trace segment: on the local root span, which owns them.
span.processTagsAndBaggage(
metaWriter, span.getLocalRootSpan() == span ? ROOT_PROPAGATION_TAGS : 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposed patch from Codex, posted at Stuart's request.

Use the collector's existing includeSamplingTags boundary flag to select the span that carries root propagation tags. A partial flush can contain only child spans; the local-root identity check then omits _dd.p.dm and _dd.p.ksr entirely. ROOT_PROPAGATION_TAGS already reads the local root's propagation tags when invoked on a child.

Suggested change
// Like the Datadog mappers, only serialize propagation tags (_dd.p.dm, _dd.p.ksr...) once per
// trace segment: on the local root span, which owns them.
span.processTagsAndBaggage(
metaWriter, span.getLocalRootSpan() == span ? ROOT_PROPAGATION_TAGS : 0);
// Export root propagation tags on the span selected by the collector for sampling tags,
// including when the exported trace contains no local root span.
span.processTagsAndBaggage(
metaWriter, metaWriter.includeSamplingTags ? ROOT_PROPAGATION_TAGS : 0);

The enclosing writer can access its nested MetaWriter's private flag, so this needs no accessor or collector changes. The flag is read before accept() resets it. Consecutive chunks of the same trace in one payload can share a single copy of these tags.

Apply the same change in both encodings. Update the current root-only regression tests to assert one exported copy per trace in the payload: protobuf prepends spans and selects the first exported span, while JSON selects the last exported span. Add coverage for a child-only partial flush and multiple trace IDs in one payload, retaining the _dd.p.tid exclusion and baggage assertions.

This is a proposed patch based on source inspection; I have not applied or run it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in ee1fa5bb57 ("Export propagation tags on partial flushes without the local root"): propagation tags now go on the span the collector selects for sampling tags, so partial flushes without the local root keep _dd.p.dm/_dd.p.ksr. Added tests for a child-only flush and multiple traces per payload (both encoders).

Comment on lines +101 to +104
// Like the Datadog mappers, only serialize propagation tags (_dd.p.dm, _dd.p.ksr...) once per
// trace segment: on the local root span, which owns them.
span.processTagsAndBaggage(
metaWriter, span.getLocalRootSpan() == span ? ROOT_PROPAGATION_TAGS : 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposed patch from Codex, posted at Stuart's request.

Use the collector's existing includeSamplingTags boundary flag to select the span that carries root propagation tags. A partial flush can contain only child spans; the local-root identity check then omits _dd.p.dm and _dd.p.ksr entirely. ROOT_PROPAGATION_TAGS already reads the local root's propagation tags when invoked on a child.

Suggested change
// Like the Datadog mappers, only serialize propagation tags (_dd.p.dm, _dd.p.ksr...) once per
// trace segment: on the local root span, which owns them.
span.processTagsAndBaggage(
metaWriter, span.getLocalRootSpan() == span ? ROOT_PROPAGATION_TAGS : 0);
// Export root propagation tags on the span selected by the collector for sampling tags,
// including when the exported trace contains no local root span.
span.processTagsAndBaggage(
metaWriter, metaWriter.includeSamplingTags ? ROOT_PROPAGATION_TAGS : 0);

The enclosing writer can access its nested MetaWriter's private flag, so this needs no accessor or collector changes. The flag is read before accept() resets it. Consecutive chunks of the same trace in one payload can share a single copy of these tags.

Apply the same change in both encodings. Update the current root-only regression tests to assert one exported copy per trace in the payload: protobuf prepends spans and selects the first exported span, while JSON selects the last exported span. Add coverage for a child-only partial flush and multiple trace IDs in one payload, retaining the _dd.p.tid exclusion and baggage assertions.

This is a proposed patch based on source inspection; I have not applied or run it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in ee1fa5bb57, same change as in the protobuf encoder. In JSON the tags now sit on the last exported span of each trace (where sampling tags already go), including child-only partial flushes. Tests cover a single trace, a child-only flush and multiple traces in one payload.

@mcculls mcculls left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some proposed patches (drafted with codex) - once those are fixed then I'll re-review

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM type: bug fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants