From 4dc5974eda0310972ca244d2077ec7a757a9b826 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 17:22:05 -0400 Subject: [PATCH 1/6] Add characterization test for non-root span _dd.p.* propagation CoreTracer span-build allocates a fresh propagationTagsFactory.empty() per local child span; only the distributed ExtractedContext path reuses the parent's. Before optimizing that per-span allocation away, this test pins the load-bearing behavior: that a non-root (local child) span still carries the inbound distributed _dd.p.* tags when it injects. Verdict (both tests pass): it does. DDSpanContext.getPropagationTags() routes to the root (getRootSpanContextOrThis), so a non-root child's own propagationTags field is never read for injection. The per-span empty() is pure allocation waste, not a latent correctness bug. This test is the gate + safety net for the planned "share the parent's PropagationTags for local children" allocation fix. Co-Authored-By: Claude Opus 4.8 --- .../core/PropagationTagsChildSpanTest.java | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java diff --git a/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java b/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java new file mode 100644 index 00000000000..d1cdc3e60e2 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java @@ -0,0 +1,98 @@ +package datadog.trace.core; + +import static datadog.trace.api.TracePropagationStyle.DATADOG; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.DDTraceId; +import datadog.trace.api.sampling.PrioritySampling; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.core.propagation.ExtractedContext; +import datadog.trace.core.propagation.PropagationTags; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Characterization test for the open question in {@code ptags-allocation-findings.md}: when a + * distributed trace arrives carrying {@code _dd.p.*} propagation tags, does a non-root (local + * child) span still carry those inbound tags when it injects? + * + *

Inbound {@code _dd.p.*} live on the root's {@link PropagationTags} (inherited from the {@link + * ExtractedContext}, {@code CoreTracer} ~line 2031), but a local child currently receives a fresh + * {@code propagationTagsFactory.empty()} instance (~line 2020). If {@link + * #localChildCarriesInboundDdpTags()} fails, non-root injection is dropping inbound {@code + * _dd.p.*} — a latent correctness bug, not merely the known per-span allocation waste. If it + * passes, there is reconciliation and the per-span empties are pure waste (sharing the + * parent's instance is then a safe allocation win). + * + *

Either way this test is the gate + safety net for the planned "share the parent's + * PropagationTags for local children" fix. + */ +class PropagationTagsChildSpanTest extends DDCoreJavaSpecification { + + private static final String INBOUND_HEADER = "_dd.p.dm=934086a686-4,_dd.p.anytag=value"; + private static final String INBOUND_TAG = "_dd.p.anytag=value"; + + private CoreTracer tracer; + + @BeforeEach + void setup() { + tracer = tracerBuilder().build(); + } + + private static ExtractedContext extractedWithDdpTags() { + return new ExtractedContext( + DDTraceId.ONE, + 2, + PrioritySampling.SAMPLER_KEEP, + null, + 0, + Collections.emptyMap(), + Collections.emptyMap(), + null, + PropagationTags.factory() + .fromHeaderValue(PropagationTags.HeaderType.DATADOG, INBOUND_HEADER), + null, + DATADOG); + } + + /** What the Datadog codec would inject for {@code x-datadog-tags} from this span. */ + private static String injectedDdpHeader(AgentSpan span) { + return ((DDSpanContext) span.context()) + .getPropagationTags() + .headerValue(PropagationTags.HeaderType.DATADOG); + } + + /** Baseline: the root span (built directly from the extracted context) carries inbound tags. */ + @Test + void rootSpanCarriesInboundDdpTags() { + AgentSpan root = tracer.buildSpan("test", "root").asChildOf(extractedWithDdpTags()).start(); + try { + String header = injectedDdpHeader(root); + assertTrue( + header != null && header.contains(INBOUND_TAG), + "root injected _dd.p.* header should contain inbound tag; was: " + header); + } finally { + root.finish(); + } + } + + /** + * THE open question: a local child of the root. Inbound {@code _dd.p.*} must survive injection + * from the child, or downstream services lose the distributed tags on every non-root hop. + */ + @Test + void localChildCarriesInboundDdpTags() { + AgentSpan root = tracer.buildSpan("test", "root").asChildOf(extractedWithDdpTags()).start(); + AgentSpan child = tracer.buildSpan("test", "child").asChildOf(root.context()).start(); + try { + String header = injectedDdpHeader(child); + assertTrue( + header != null && header.contains(INBOUND_TAG), + "local child injected _dd.p.* header should contain inbound tag; was: " + header); + } finally { + child.finish(); + root.finish(); + } + } +} From c02635a44f946204aa8d04fc506af77877c71523 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 22 Jun 2026 17:27:09 -0400 Subject: [PATCH 2/6] Share parent's PropagationTags for local child spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoreTracer span-build allocated a fresh propagationTagsFactory.empty() for every local child span, reused from the parent only on the distributed ExtractedContext path — N+1 PropagationTags per N-span trace, N of them needless empties. A non-root child's own PropagationTags is never read for injection: DDSpanContext.getPropagationTags() routes to the root (getRootSpanContextOrThis), confirmed by PropagationTagsChildSpanTest (inbound _dd.p.* survive child injection). So local children can share the parent's (trace-level) instance instead of allocating their own, collapsing N+1 -> 1 per trace. Safe: the ctor's updateTraceIdHighOrderBits stamp is guard-idempotent and a no-op on the already-stamped shared root instance (same trace => same high-order bits). Full dd-trace-core propagation + span-build suite passes. Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/datadog/trace/core/CoreTracer.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index 7ad4497beca..3fb852f07ed 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -2031,7 +2031,12 @@ protected static final DDSpanContext buildSpanContext( requestContextDataIast = null; ciVisibilityContextData = null; } - propagationTags = tracer.propagationTagsFactory.empty(); + // Local child: share the parent's (trace-level) PropagationTags instead of allocating a + // fresh empty() per span. getPropagationTags() routes to the root, so a non-root child's + // own instance is never read for injection — the per-span empty() was pure allocation + // waste (N+1 -> 1 PropagationTags per trace). The ctor's updateTraceIdHighOrderBits stamp + // is a guarded no-op on the already-stamped root instance (same trace => same high bits). + propagationTags = ddsc.getPropagationTags(); } else { long endToEndStartTime; From 233ffacd7e06bf945e3f68d22de97a796579c272 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 23 Jun 2026 12:01:37 -0400 Subject: [PATCH 3/6] Simplify the share-PropagationTags comment (review feedback) Co-Authored-By: Claude Opus 4.8 --- .../src/main/java/datadog/trace/core/CoreTracer.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index 3fb852f07ed..ba5d454c619 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -2031,11 +2031,8 @@ protected static final DDSpanContext buildSpanContext( requestContextDataIast = null; ciVisibilityContextData = null; } - // Local child: share the parent's (trace-level) PropagationTags instead of allocating a - // fresh empty() per span. getPropagationTags() routes to the root, so a non-root child's - // own instance is never read for injection — the per-span empty() was pure allocation - // waste (N+1 -> 1 PropagationTags per trace). The ctor's updateTraceIdHighOrderBits stamp - // is a guarded no-op on the already-stamped root instance (same trace => same high bits). + // Local children share the parent's PropagationTags (trace-level state; reads route to the + // root) instead of allocating an unused empty() per span. propagationTags = ddsc.getPropagationTags(); } else { long endToEndStartTime; From 817e2c236566b80d2cfc8b30f8afe34b55912655 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 29 Jun 2026 09:51:28 -0400 Subject: [PATCH 4/6] Reframe PropagationTagsChildSpanTest Javadoc as a regression guard The change landed, so drop the pre-implementation 'open question / planned fix' framing, the scratch-doc reference (ptags-allocation-findings.md), and the brittle CoreTracer line numbers. State what it guards: local children share the root's PropagationTags, and reads route to the root, so a non-root span still injects the inbound _dd.p.* tags. Co-Authored-By: Claude Opus 4.8 --- .../core/PropagationTagsChildSpanTest.java | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java b/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java index d1cdc3e60e2..3eaff2266f2 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java @@ -13,20 +13,16 @@ import org.junit.jupiter.api.Test; /** - * Characterization test for the open question in {@code ptags-allocation-findings.md}: when a - * distributed trace arrives carrying {@code _dd.p.*} propagation tags, does a non-root (local - * child) span still carry those inbound tags when it injects? + * Regression guard for the "local children share the parent's {@link PropagationTags}" optimization + * in {@code CoreTracer.buildSpanContext}: a non-root (local child) span must still carry the + * inbound distributed {@code _dd.p.*} tags when it injects. * - *

Inbound {@code _dd.p.*} live on the root's {@link PropagationTags} (inherited from the {@link - * ExtractedContext}, {@code CoreTracer} ~line 2031), but a local child currently receives a fresh - * {@code propagationTagsFactory.empty()} instance (~line 2020). If {@link - * #localChildCarriesInboundDdpTags()} fails, non-root injection is dropping inbound {@code - * _dd.p.*} — a latent correctness bug, not merely the known per-span allocation waste. If it - * passes, there is reconciliation and the per-span empties are pure waste (sharing the - * parent's instance is then a safe allocation win). - * - *

Either way this test is the gate + safety net for the planned "share the parent's - * PropagationTags for local children" fix. + *

Inbound {@code _dd.p.*} live on the root's {@code PropagationTags} (inherited from the {@link + * ExtractedContext}), and reads route to the root via {@code DDSpanContext.getPropagationTags()} — + * so a local child injects the same tags whether it holds its own instance or shares the root's. + * That invariant is what lets the child skip allocating its own {@code empty()} and share the + * root's instead. If {@link #localChildCarriesInboundDdpTags()} regresses, non-root injection is + * dropping inbound {@code _dd.p.*} on every hop. */ class PropagationTagsChildSpanTest extends DDCoreJavaSpecification { From e5aa0f60fa55a5620100c02a4dc5e93e733bbd0a Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 29 Jun 2026 17:46:50 -0400 Subject: [PATCH 5/6] Fix PropagationTagsChildSpanTest compile: context() -> spanContext() The master merge renamed AgentSpan.context() to spanContext(); update the two call sites so :dd-trace-core:compileTestJava passes. Co-Authored-By: Claude Opus 4.8 --- .../java/datadog/trace/core/PropagationTagsChildSpanTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java b/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java index 3eaff2266f2..1f20e58c661 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java @@ -54,7 +54,7 @@ private static ExtractedContext extractedWithDdpTags() { /** What the Datadog codec would inject for {@code x-datadog-tags} from this span. */ private static String injectedDdpHeader(AgentSpan span) { - return ((DDSpanContext) span.context()) + return ((DDSpanContext) span.spanContext()) .getPropagationTags() .headerValue(PropagationTags.HeaderType.DATADOG); } @@ -80,7 +80,7 @@ void rootSpanCarriesInboundDdpTags() { @Test void localChildCarriesInboundDdpTags() { AgentSpan root = tracer.buildSpan("test", "root").asChildOf(extractedWithDdpTags()).start(); - AgentSpan child = tracer.buildSpan("test", "child").asChildOf(root.context()).start(); + AgentSpan child = tracer.buildSpan("test", "child").asChildOf(root.spanContext()).start(); try { String header = injectedDdpHeader(child); assertTrue( From 76851be35b0d0be686dda51c6802fc3ed7ece1b4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 14 Jul 2026 11:33:16 -0400 Subject: [PATCH 6/6] Gate trace-level propagation-tag emit to the local root Local children now share the root's PropagationTags, so DDSpanContext.processTagsAndBaggage emitted _dd.p.* (including the _dd.p.dm decision-maker) into every child span's metadata -- the emit was previously gated only implicitly by children holding an empty() instance. Gate it on span position (local root only), which exactly restores pre-PR behavior. All three trace mappers read per-span _dd.p.* from Metadata.getBaggage(), so the one gate covers them. Caught by the test_sampling_span_tags parametric test; add a serialization-side case to PropagationTagsChildSpanTest that fails without the gate and passes with it. Co-Authored-By: Claude Opus 4.8 --- .../datadog/trace/core/DDSpanContext.java | 12 +++- .../core/PropagationTagsChildSpanTest.java | 63 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 6120502ec09..394a721fdd9 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -1238,14 +1238,22 @@ void processTagsAndBaggage( // Baggage Map baggageItemsWithPropagationTags; + // Trace-level propagation tags (_dd.p.*, including the _dd.p.dm decision-maker) belong only + // on the local root. Local children now share the root's PropagationTags instance, so gate + // the emit on span position; otherwise the trace-level tags would be duplicated onto every + // child span. + final boolean isLocalRoot = getRootSpanContextIfDifferent() == null; if (injectBaggageAsTags) { baggageItemsWithPropagationTags = new HashMap<>(baggageItems); if (w3cBaggage != null) { injectW3CBaggageTags(baggageItemsWithPropagationTags); } - propagationTags.fillTagMap(baggageItemsWithPropagationTags); + if (isLocalRoot) { + propagationTags.fillTagMap(baggageItemsWithPropagationTags); + } } else { - baggageItemsWithPropagationTags = propagationTags.createTagMap(); + baggageItemsWithPropagationTags = + isLocalRoot ? propagationTags.createTagMap() : new HashMap<>(); } consumer.accept( diff --git a/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java b/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java index 1f20e58c661..184e10c1212 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/PropagationTagsChildSpanTest.java @@ -1,6 +1,7 @@ package datadog.trace.core; import static datadog.trace.api.TracePropagationStyle.DATADOG; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import datadog.trace.api.DDTraceId; @@ -9,6 +10,7 @@ import datadog.trace.core.propagation.ExtractedContext; import datadog.trace.core.propagation.PropagationTags; import java.util.Collections; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,6 +25,13 @@ * That invariant is what lets the child skip allocating its own {@code empty()} and share the * root's instead. If {@link #localChildCarriesInboundDdpTags()} regresses, non-root injection is * dropping inbound {@code _dd.p.*} on every hop. + * + *

Sharing has a second, opposite hazard on the serialization side: trace-level {@code + * _dd.p.*} must be emitted into a span's metadata only on the local root. Since children share the + * root's non-empty instance, {@code DDSpanContext.processTagsAndBaggage} gates the emit on span + * position — otherwise {@code _dd.p.dm} (and friends) would be duplicated onto every child span in + * the chunk, which the {@code test_sampling_span_tags} parametric test forbids. {@link + * #localChildDoesNotEmitTraceLevelDdpTags()} guards that direction. */ class PropagationTagsChildSpanTest extends DDCoreJavaSpecification { @@ -91,4 +100,58 @@ void localChildCarriesInboundDdpTags() { root.finish(); } } + + /** The trace-level {@code _dd.p.*} a span would emit into its serialized metadata (baggage). */ + private static Map emittedPropagationTags(AgentSpan span) { + CapturingConsumer consumer = new CapturingConsumer(); + ((DDSpan) span).processTagsAndBaggage(consumer); + return consumer.metadata.getBaggage(); + } + + /** Serialization baseline: the local root emits the trace-level decision-maker tag. */ + @Test + void rootSpanEmitsDecisionMakerTag() { + AgentSpan root = tracer.buildSpan("test", "root").asChildOf(extractedWithDdpTags()).start(); + try { + Map emitted = emittedPropagationTags(root); + assertTrue( + emitted.containsKey("_dd.p.dm"), + "root serialized metadata should carry _dd.p.dm; was: " + emitted); + } finally { + root.finish(); + } + } + + /** + * Regression guard for the {@code test_sampling_span_tags} parametric test: a non-root (local + * child) span must not emit trace-level {@code _dd.p.*} into its serialized metadata. Because the + * child shares the root's {@link PropagationTags}, an ungated emit duplicates {@code _dd.p.dm} + * onto every child span in the chunk. + */ + @Test + void localChildDoesNotEmitTraceLevelDdpTags() { + AgentSpan root = tracer.buildSpan("test", "root").asChildOf(extractedWithDdpTags()).start(); + AgentSpan child = tracer.buildSpan("test", "child").asChildOf(root.spanContext()).start(); + try { + Map emitted = emittedPropagationTags(child); + assertFalse( + emitted.containsKey("_dd.p.dm"), + "local child serialized metadata must not carry trace-level _dd.p.dm; was: " + emitted); + assertFalse( + emitted.containsKey("_dd.p.anytag"), + "local child serialized metadata must not carry trace-level _dd.p.*; was: " + emitted); + } finally { + child.finish(); + root.finish(); + } + } + + private static final class CapturingConsumer implements MetadataConsumer { + private Metadata metadata; + + @Override + public void accept(Metadata metadata) { + this.metadata = metadata; + } + } }