From 896945629b28d433ff6ff975e38ea94e79ead4b1 Mon Sep 17 00:00:00 2001 From: Anna Date: Wed, 21 Oct 2026 17:56:04 +0200 Subject: [PATCH 01/10] Export propagation tags as OTLP span attributes 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 --- .../trace/core/otlp/trace/OtlpTraceJson.java | 20 ++- .../trace/core/otlp/trace/OtlpTraceProto.java | 22 +++- .../trace/OtlpTraceJsonCollectorTest.java | 43 +++++++ .../core/otlp/trace/OtlpTraceProtoTest.java | 115 ++++++++++++++++++ 4 files changed, 194 insertions(+), 6 deletions(-) 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 index 1575b26d228..3cefa547ba1 100644 --- 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 @@ -14,7 +14,7 @@ import static datadog.trace.common.writer.ddagent.TraceMapper.SAMPLING_PRIORITY_KEY; import static datadog.trace.common.writer.ddagent.TraceMapper.THREAD_ID; import static datadog.trace.common.writer.ddagent.TraceMapper.THREAD_NAME; -import static datadog.trace.core.CoreSpan.OWN_PROPAGATION_TAGS; +import static datadog.trace.core.CoreSpan.ROOT_PROPAGATION_TAGS; import static datadog.trace.core.otlp.common.OtlpCommonJson.hexSpanId; import static datadog.trace.core.otlp.common.OtlpCommonJson.hexTraceId; import static datadog.trace.core.otlp.common.OtlpCommonJson.writeAttribute; @@ -113,7 +113,10 @@ public static void writeSpan( if (span.getSpanType() != null) { writeSpanTag(writer, SPAN_TYPE, span.getSpanType()); } - span.processTagsAndBaggage(metaWriter, OWN_PROPAGATION_TAGS); + // Like the Datadog mappers, only serialize propagation tags (_dd.p.dm, _dd.p.tid...) once per + // trace segment: on the local root span, which owns them. + span.processTagsAndBaggage( + metaWriter, span.getLocalRootSpan() == span ? ROOT_PROPAGATION_TAGS : 0); writer.endArray(); if (!links.isEmpty()) { @@ -252,8 +255,19 @@ public void accept(Metadata metadata) { writeSpanTag(writer, PROCESS_TAGS_KEY, metadata.processTags()); } - // The direction is computed once per span; boxing -1..2 hits the Integer cache. 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 win on a key clash, + // which also keeps OTLP attribute keys unique. + for (Map.Entry entry : metadata.getBaggage().entrySet()) { + if (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); 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 index ab64bde46cd..10d14e35aa6 100644 --- 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 @@ -14,7 +14,7 @@ import static datadog.trace.common.writer.ddagent.TraceMapper.SAMPLING_PRIORITY_KEY; import static datadog.trace.common.writer.ddagent.TraceMapper.THREAD_ID; import static datadog.trace.common.writer.ddagent.TraceMapper.THREAD_NAME; -import static datadog.trace.core.CoreSpan.OWN_PROPAGATION_TAGS; +import static datadog.trace.core.CoreSpan.ROOT_PROPAGATION_TAGS; import static datadog.trace.core.otlp.common.OtlpCommonProto.I32_WIRE_TYPE; import static datadog.trace.core.otlp.common.OtlpCommonProto.I64_WIRE_TYPE; import static datadog.trace.core.otlp.common.OtlpCommonProto.LEN_WIRE_TYPE; @@ -51,6 +51,7 @@ import datadog.trace.core.otlp.common.OtlpProtoBuffer; import datadog.trace.core.propagation.PropagationTags; import datadog.trace.core.propagation.PropagationTags.SamplingState; +import java.util.Map; /** Provides optimized writers for OpenTelemetry's "trace.proto" wire protocol. */ public final class OtlpTraceProto { @@ -160,7 +161,10 @@ public static int recordSpanMessage( writeSpanTag(buf, SPAN_TYPE, span.getSpanType()); } - span.processTagsAndBaggage(metaWriter, OWN_PROPAGATION_TAGS); + // Like the Datadog mappers, only serialize propagation tags (_dd.p.dm, _dd.p.tid...) once per + // trace segment: on the local root span, which owns them. + span.processTagsAndBaggage( + metaWriter, span.getLocalRootSpan() == span ? ROOT_PROPAGATION_TAGS : 0); if (span.isError()) { int stateSize = 2; @@ -329,8 +333,20 @@ public void accept(Metadata metadata) { writeSpanTag(buf, PROCESS_TAGS_KEY, metadata.processTags()); } - // The direction is computed once per span; boxing -1..2 hits the Integer cache. 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 win on a key clash, + // which also keeps OTLP attribute keys unique. + for (Map.Entry entry : metadata.getBaggage().entrySet()) { + if (tags.containsKey(entry.getKey())) { + continue; + } + writeTag(buf, 9, LEN_WIRE_TYPE); + writeAttribute(buf, 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(buf, direction, OtlpTraceProto::writeSpanTag); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java index 384f903efd0..3928a95014e 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java @@ -396,6 +396,42 @@ void multipleSpansInATraceAreAllWritten() throws IOException { assertEquals(hexSpanId(((DDSpan) parent).getSpanId()), parsedChild.get("parentSpanId")); } + @Test + void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { + AgentSpan root = TRACER.startSpan("test", "op.root"); + root.setResourceName("root.op"); + ((DDSpan) root) + .spanContext() + .getPropagationTags() + .tryUpdateProbabilitySamplingDecision( + PrioritySampling.USER_KEEP, + SamplingMechanism.REMOTE_ADAPTIVE_RULE, + 0.5, + false, + root.getTraceId().toLong(), + true); + root.setBaggageItem("baggage.key", "baggage.value"); + AgentSpan child = TRACER.startSpan("test", "op.child", root.spanContext()); + child.setResourceName("child.op"); + child.finish(); + root.finish(); + + OtlpTraceJsonCollector collector = new OtlpTraceJsonCollector(); + collector.addTrace(asList((CoreSpan) root, (CoreSpan) child)); + List> parsedSpans = allSpans(collector.collectTraces()); + + Map parsedRoot = spanNamed(parsedSpans, "root.op"); + assertEquals(asList("-12"), attributeValues(parsedRoot, "_dd.p.dm")); + assertEquals(asList("0.5"), attributeValues(parsedRoot, "_dd.p.ksr")); + assertEquals(asList("baggage.value"), attributeValues(parsedRoot, "baggage.key")); + + Map parsedChild = spanNamed(parsedSpans, "child.op"); + for (String key : attributeKeys(parsedChild)) { + assertFalse(key.startsWith("_dd.p."), "child span must not carry " + key); + } + assertEquals(asList("baggage.value"), attributeValues(parsedChild, "baggage.key")); + } + @Test void poisonedSpanResetsCollectorForNextTrace() throws IOException { // mid-trace exception (e.g. from a malformed span) must not leave partial state behind @@ -545,6 +581,13 @@ private static List> allSpans(OtlpPayload payload) throws IO return result; } + private static Map spanNamed(List> spans, String name) { + return spans.stream() + .filter(s -> name.equals(s.get("name"))) + .findFirst() + .orElseThrow(() -> new AssertionError(name + " span not found")); + } + @SuppressWarnings("unchecked") private static Map onlyLink(Map span) { List links = (List) span.get("links"); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java index 1402c16c7b3..4758cadbfe8 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java @@ -734,6 +734,121 @@ void poisonedSpanResetsCollectorForNextTrace() { assertTrue(payload.getContentLength() > 0, "normal trace after reset must still export"); } + @Test + void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { + AgentSpan root = TRACER.startSpan("test", "op.root"); + root.setResourceName("root.op"); + ((DDSpan) root) + .spanContext() + .getPropagationTags() + .tryUpdateProbabilitySamplingDecision( + PrioritySampling.USER_KEEP, + SamplingMechanism.REMOTE_USER_RULE, + 0.5, + false, + root.getTraceId().toLong(), + true); + root.setBaggageItem("baggage.key", "baggage.value"); + AgentSpan child = TRACER.startSpan("test", "op.child", root.spanContext()); + child.setResourceName("child.op"); + child.finish(); + root.finish(); + + OtlpTraceProtoCollector collector = new OtlpTraceProtoCollector(); + collector.addTrace(asList((CoreSpan) root, (CoreSpan) child)); + Map> attributesBySpan = + parseStringAttributesBySpanName(collector.collectTraces()); + + Map rootAttributes = attributesBySpan.get("root.op"); + assertNotNull(rootAttributes, "root span must be exported"); + assertEquals("-11", rootAttributes.get("_dd.p.dm")); + assertEquals("0.5", rootAttributes.get("_dd.p.ksr")); + assertEquals("baggage.value", rootAttributes.get("baggage.key")); + + Map childAttributes = attributesBySpan.get("child.op"); + assertNotNull(childAttributes, "child span must be exported"); + for (String key : childAttributes.keySet()) { + assertFalse(key.startsWith("_dd.p."), "child span must not carry " + key); + } + assertEquals("baggage.value", childAttributes.get("baggage.key")); + } + + /** Maps each span name to its string-valued attributes. */ + private static Map> parseStringAttributesBySpanName( + OtlpPayload payload) throws IOException { + CodedInputStream tracesData = CodedInputStream.newInstance(payload.getContent()); + tracesData.readTag(); // field 1: TracesData.resource_spans + CodedInputStream resourceSpans = tracesData.readBytes().newCodedInput(); + Map> result = new HashMap<>(); + while (!resourceSpans.isAtEnd()) { + int tag = resourceSpans.readTag(); + if (WireFormat.getTagFieldNumber(tag) != 2) { + resourceSpans.skipField(tag); + continue; + } + CodedInputStream scopeSpans = resourceSpans.readBytes().newCodedInput(); + while (!scopeSpans.isAtEnd()) { + int scopeTag = scopeSpans.readTag(); + if (WireFormat.getTagFieldNumber(scopeTag) != 2) { + scopeSpans.skipField(scopeTag); + continue; + } + CodedInputStream spanData = scopeSpans.readBytes().newCodedInput(); + String name = null; + Map attributes = new HashMap<>(); + while (!spanData.isAtEnd()) { + int spanTag = spanData.readTag(); + switch (WireFormat.getTagFieldNumber(spanTag)) { + case 5: + name = spanData.readString(); + break; + case 9: + readStringKeyValue(spanData.readBytes().newCodedInput(), attributes); + break; + default: + spanData.skipField(spanTag); + } + } + result.put(name, attributes); + } + } + return result; + } + + /** + * Reads a {@code KeyValue { string key = 1; AnyValue value = 2; }} into the map when its value is + * an {@code AnyValue { string string_value = 1; }}. + */ + private static void readStringKeyValue(CodedInputStream keyValue, Map into) + throws IOException { + String key = null; + String value = null; + while (!keyValue.isAtEnd()) { + int tag = keyValue.readTag(); + switch (WireFormat.getTagFieldNumber(tag)) { + case 1: + key = keyValue.readString(); + break; + case 2: + CodedInputStream anyValue = keyValue.readBytes().newCodedInput(); + while (!anyValue.isAtEnd()) { + int valueTag = anyValue.readTag(); + if (WireFormat.getTagFieldNumber(valueTag) == 1) { + value = anyValue.readString(); + } else { + anyValue.skipField(valueTag); + } + } + break; + default: + keyValue.skipField(tag); + } + } + if (key != null && value != null) { + into.put(key, value); + } + } + @Test void testSpanOrderInTracePreserved() throws IOException { // Verifies that spans appear in the payload with the same order as the original trace From 900a3071d10791accd80e7ba88e690e51172c581 Mon Sep 17 00:00:00 2001 From: Anna Date: Thu, 22 Oct 2026 13:54:58 +0200 Subject: [PATCH 02/10] Don't export _dd.p.tid as an OTLP attribute The OTLP traceId already carries all 128 bits, so the tag is redundant. Co-Authored-By: Claude Opus 5.5 --- .../java/datadog/trace/core/otlp/trace/OtlpTraceJson.java | 8 +++++--- .../datadog/trace/core/otlp/trace/OtlpTraceProto.java | 8 +++++--- .../trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java | 5 +++++ .../datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java | 4 ++++ .../datadog/trace/bootstrap/instrumentation/api/Tags.java | 1 + 5 files changed, 20 insertions(+), 6 deletions(-) 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 index 3cefa547ba1..fba233f9958 100644 --- 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 @@ -5,6 +5,7 @@ import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_PARTIAL_VERSION; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_TOP_LEVEL; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_WAS_LONG_RUNNING; +import static datadog.trace.bootstrap.instrumentation.api.Tags.PROPAGATED_TRACE_ID_HIGH; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.BOOLEAN_ATTRIBUTE; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.DOUBLE_ATTRIBUTE; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.LONG_ATTRIBUTE; @@ -113,7 +114,7 @@ public static void writeSpan( if (span.getSpanType() != null) { writeSpanTag(writer, SPAN_TYPE, span.getSpanType()); } - // Like the Datadog mappers, only serialize propagation tags (_dd.p.dm, _dd.p.tid...) once per + // 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); @@ -259,9 +260,10 @@ public void accept(Metadata metadata) { // 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 win on a key clash, - // which also keeps OTLP attribute keys unique. + // 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 entry : metadata.getBaggage().entrySet()) { - if (tags.containsKey(entry.getKey())) { + if (PROPAGATED_TRACE_ID_HIGH.equals(entry.getKey()) || tags.containsKey(entry.getKey())) { continue; } writeAttribute(writer, STRING_ATTRIBUTE, entry.getKey(), entry.getValue()); 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 index 10d14e35aa6..907ac456473 100644 --- 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 @@ -5,6 +5,7 @@ import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_PARTIAL_VERSION; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_TOP_LEVEL; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_WAS_LONG_RUNNING; +import static datadog.trace.bootstrap.instrumentation.api.Tags.PROPAGATED_TRACE_ID_HIGH; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.BOOLEAN_ATTRIBUTE; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.DOUBLE_ATTRIBUTE; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.LONG_ATTRIBUTE; @@ -161,7 +162,7 @@ public static int recordSpanMessage( writeSpanTag(buf, SPAN_TYPE, span.getSpanType()); } - // Like the Datadog mappers, only serialize propagation tags (_dd.p.dm, _dd.p.tid...) once per + // 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); @@ -337,9 +338,10 @@ public void accept(Metadata metadata) { // 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 win on a key clash, - // which also keeps OTLP attribute keys unique. + // 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 entry : metadata.getBaggage().entrySet()) { - if (tags.containsKey(entry.getKey())) { + if (PROPAGATED_TRACE_ID_HIGH.equals(entry.getKey()) || tags.containsKey(entry.getKey())) { continue; } writeTag(buf, 9, LEN_WIRE_TYPE); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java index 3928a95014e..ee4d3aca206 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java @@ -9,6 +9,7 @@ import static java.util.Arrays.asList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -400,6 +401,8 @@ void multipleSpansInATraceAreAllWritten() throws IOException { void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { AgentSpan root = TRACER.startSpan("test", "op.root"); root.setResourceName("root.op"); + // 128-bit trace ids are on by default, so the root carries a _dd.p.tid propagation tag + assertNotEquals(0, root.getTraceId().toHighOrderLong()); ((DDSpan) root) .spanContext() .getPropagationTags() @@ -423,6 +426,8 @@ void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { Map parsedRoot = spanNamed(parsedSpans, "root.op"); assertEquals(asList("-12"), attributeValues(parsedRoot, "_dd.p.dm")); assertEquals(asList("0.5"), attributeValues(parsedRoot, "_dd.p.ksr")); + // the OTLP traceId already carries the upper 64 bits + assertFalse(attributeKeys(parsedRoot).contains("_dd.p.tid"), "_dd.p.tid must not be exported"); assertEquals(asList("baggage.value"), attributeValues(parsedRoot, "baggage.key")); Map parsedChild = spanNamed(parsedSpans, "child.op"); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java index 4758cadbfe8..45f4b95b007 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java @@ -738,6 +738,8 @@ void poisonedSpanResetsCollectorForNextTrace() { void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { AgentSpan root = TRACER.startSpan("test", "op.root"); root.setResourceName("root.op"); + // 128-bit trace ids are on by default, so the root carries a _dd.p.tid propagation tag + assertNotEquals(0, root.getTraceId().toHighOrderLong()); ((DDSpan) root) .spanContext() .getPropagationTags() @@ -763,6 +765,8 @@ void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { assertNotNull(rootAttributes, "root span must be exported"); assertEquals("-11", rootAttributes.get("_dd.p.dm")); assertEquals("0.5", rootAttributes.get("_dd.p.ksr")); + // the OTLP traceId already carries the upper 64 bits + assertFalse(rootAttributes.containsKey("_dd.p.tid"), "_dd.p.tid must not be exported"); assertEquals("baggage.value", rootAttributes.get("baggage.key")); Map childAttributes = attributesBySpan.get("child.op"); diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java index 12bbb0b9502..b41f3027864 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java @@ -188,6 +188,7 @@ public class Tags { public static final String PROPAGATED_TRACE_SOURCE = "_dd.p.ts"; public static final String PROPAGATED_DEBUG = "_dd.p.debug"; + public static final String PROPAGATED_TRACE_ID_HIGH = "_dd.p.tid"; public static final String LLMOBS_LLM_SPAN_KIND = "llm"; public static final String LLMOBS_WORKFLOW_SPAN_KIND = "workflow"; From 6dce281f7bab63f8165c5c734e1e0f13c611b505 Mon Sep 17 00:00:00 2001 From: Anna Date: Thu, 22 Oct 2026 14:14:49 +0200 Subject: [PATCH 03/10] Keep the _dd.p.tid key local to the OTLP writers 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 --- .../java/datadog/trace/core/otlp/trace/OtlpTraceJson.java | 5 +++-- .../java/datadog/trace/core/otlp/trace/OtlpTraceProto.java | 5 +++-- .../datadog/trace/bootstrap/instrumentation/api/Tags.java | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) 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 index fba233f9958..84c81038dc8 100644 --- 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 @@ -5,7 +5,6 @@ import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_PARTIAL_VERSION; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_TOP_LEVEL; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_WAS_LONG_RUNNING; -import static datadog.trace.bootstrap.instrumentation.api.Tags.PROPAGATED_TRACE_ID_HIGH; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.BOOLEAN_ATTRIBUTE; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.DOUBLE_ATTRIBUTE; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.LONG_ATTRIBUTE; @@ -48,6 +47,8 @@ public final class OtlpTraceJson { private static final UTF8BytesString OPERATION_NAME = UTF8BytesString.create("operation.name"); private static final UTF8BytesString SPAN_TYPE = UTF8BytesString.create("span.type"); + private static final String TRACE_ID_HIGH_TAG = "_dd.p.tid"; + /* * Span fields and intercepted Metadata fields bypass TagMap entry projection. Resolve their * keys through the registry here so JSON and protobuf use the same OpenTelemetry names. @@ -263,7 +264,7 @@ public void accept(Metadata metadata) { // 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 entry : metadata.getBaggage().entrySet()) { - if (PROPAGATED_TRACE_ID_HIGH.equals(entry.getKey()) || tags.containsKey(entry.getKey())) { + if (TRACE_ID_HIGH_TAG.equals(entry.getKey()) || tags.containsKey(entry.getKey())) { continue; } writeAttribute(writer, STRING_ATTRIBUTE, entry.getKey(), entry.getValue()); 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 index 907ac456473..296f7b2e3a5 100644 --- 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 @@ -5,7 +5,6 @@ import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_PARTIAL_VERSION; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_TOP_LEVEL; import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_WAS_LONG_RUNNING; -import static datadog.trace.bootstrap.instrumentation.api.Tags.PROPAGATED_TRACE_ID_HIGH; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.BOOLEAN_ATTRIBUTE; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.DOUBLE_ATTRIBUTE; import static datadog.trace.bootstrap.otlp.common.OtlpAttributeVisitor.LONG_ATTRIBUTE; @@ -61,6 +60,8 @@ public final class OtlpTraceProto { private static final UTF8BytesString OPERATION_NAME = UTF8BytesString.create("operation.name"); private static final UTF8BytesString SPAN_TYPE = UTF8BytesString.create("span.type"); + private static final String TRACE_ID_HIGH_TAG = "_dd.p.tid"; + /* * Span fields and intercepted Metadata fields bypass TagMap entry projection. Resolve their * keys through the registry here so OpenTelemetry names stay consistent with entry tags. @@ -341,7 +342,7 @@ public void accept(Metadata metadata) { // 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 entry : metadata.getBaggage().entrySet()) { - if (PROPAGATED_TRACE_ID_HIGH.equals(entry.getKey()) || tags.containsKey(entry.getKey())) { + if (TRACE_ID_HIGH_TAG.equals(entry.getKey()) || tags.containsKey(entry.getKey())) { continue; } writeTag(buf, 9, LEN_WIRE_TYPE); diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java index b41f3027864..12bbb0b9502 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java @@ -188,7 +188,6 @@ public class Tags { public static final String PROPAGATED_TRACE_SOURCE = "_dd.p.ts"; public static final String PROPAGATED_DEBUG = "_dd.p.debug"; - public static final String PROPAGATED_TRACE_ID_HIGH = "_dd.p.tid"; public static final String LLMOBS_LLM_SPAN_KIND = "llm"; public static final String LLMOBS_WORKFLOW_SPAN_KIND = "workflow"; From a0096d2748718cffa76971947867ab5a88b8f916 Mon Sep 17 00:00:00 2001 From: Anna Date: Fri, 23 Oct 2026 15:53:45 +0200 Subject: [PATCH 04/10] Don't let baggage duplicate directly emitted OTLP attributes Co-Authored-By: Claude Opus 5.5 --- .../trace/core/otlp/trace/OtlpTraceJson.java | 37 ++++++++-- .../trace/core/otlp/trace/OtlpTraceProto.java | 37 ++++++++-- .../trace/OtlpTraceJsonCollectorTest.java | 19 +++++ .../core/otlp/trace/OtlpTraceProtoTest.java | 72 ++++++++++++++++--- 4 files changed, 143 insertions(+), 22 deletions(-) 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 index 84c81038dc8..0faa2108455 100644 --- 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 @@ -37,8 +37,11 @@ import datadog.trace.core.PendingTrace; import datadog.trace.core.propagation.PropagationTags; import datadog.trace.core.propagation.PropagationTags.SamplingState; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** Provides writers for OpenTelemetry's "trace.proto" JSON encoding. */ public final class OtlpTraceJson { @@ -47,8 +50,6 @@ public final class OtlpTraceJson { private static final UTF8BytesString OPERATION_NAME = UTF8BytesString.create("operation.name"); private static final UTF8BytesString SPAN_TYPE = UTF8BytesString.create("span.type"); - private static final String TRACE_ID_HIGH_TAG = "_dd.p.tid"; - /* * Span fields and intercepted Metadata fields bypass TagMap entry projection. Resolve their * keys through the registry here so JSON and protobuf use the same OpenTelemetry names. @@ -66,6 +67,31 @@ private static UTF8BytesString otelKey(long tagId) { return UTF8BytesString.create(KnownTagCodec.openTelemetryTagOf(tagId)); } + /** + * Keys that baggage must not export as attributes: the attributes this writer emits directly from + * span fields and intercepted Metadata fields (the span's own value wins), plus _dd.p.tid, whose + * upper 64 bits the OTLP traceId already carries. + */ + private static final Set SKIPPED_BAGGAGE_KEYS = + new HashSet<>( + Arrays.asList( + "_dd.p.tid", + SERVICE_NAME_KEY.toString(), + RESOURCE_NAME.toString(), + OPERATION_NAME.toString(), + SPAN_TYPE.toString(), + SAMPLING_PRIORITY_KEY.toString(), + DD_MEASURED.toString(), + DD_TOP_LEVEL.toString(), + DD_PARTIAL_VERSION.toString(), + DD_WAS_LONG_RUNNING.toString(), + THREAD_ID.toString(), + THREAD_NAME.toString(), + HTTP_STATUS_CODE_KEY.toString(), + HTTP_STATUS_CODE_KEY_DD.toString(), + ORIGIN_KEY.toString(), + PROCESS_TAGS_KEY.toString())); + private OtlpTraceJson() {} /** Writes one complete {@code Span} JSON object. */ @@ -260,11 +286,10 @@ public void accept(Metadata metadata) { 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 win on a key clash, - // 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. + // 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 entry : metadata.getBaggage().entrySet()) { - if (TRACE_ID_HIGH_TAG.equals(entry.getKey()) || tags.containsKey(entry.getKey())) { + if (SKIPPED_BAGGAGE_KEYS.contains(entry.getKey()) || tags.containsKey(entry.getKey())) { continue; } writeAttribute(writer, STRING_ATTRIBUTE, entry.getKey(), entry.getValue()); 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 index 296f7b2e3a5..00813dfb881 100644 --- 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 @@ -51,7 +51,10 @@ import datadog.trace.core.otlp.common.OtlpProtoBuffer; import datadog.trace.core.propagation.PropagationTags; import datadog.trace.core.propagation.PropagationTags.SamplingState; +import java.util.Arrays; +import java.util.HashSet; import java.util.Map; +import java.util.Set; /** Provides optimized writers for OpenTelemetry's "trace.proto" wire protocol. */ public final class OtlpTraceProto { @@ -60,8 +63,6 @@ public final class OtlpTraceProto { private static final UTF8BytesString OPERATION_NAME = UTF8BytesString.create("operation.name"); private static final UTF8BytesString SPAN_TYPE = UTF8BytesString.create("span.type"); - private static final String TRACE_ID_HIGH_TAG = "_dd.p.tid"; - /* * Span fields and intercepted Metadata fields bypass TagMap entry projection. Resolve their * keys through the registry here so OpenTelemetry names stay consistent with entry tags. @@ -79,6 +80,31 @@ private static UTF8BytesString otelKey(long tagId) { return UTF8BytesString.create(KnownTagCodec.openTelemetryTagOf(tagId)); } + /** + * Keys that baggage must not export as attributes: the attributes this writer emits directly from + * span fields and intercepted Metadata fields (the span's own value wins), plus _dd.p.tid, whose + * upper 64 bits the OTLP traceId already carries. + */ + private static final Set SKIPPED_BAGGAGE_KEYS = + new HashSet<>( + Arrays.asList( + "_dd.p.tid", + SERVICE_NAME_KEY.toString(), + RESOURCE_NAME.toString(), + OPERATION_NAME.toString(), + SPAN_TYPE.toString(), + SAMPLING_PRIORITY_KEY.toString(), + DD_MEASURED.toString(), + DD_TOP_LEVEL.toString(), + DD_PARTIAL_VERSION.toString(), + DD_WAS_LONG_RUNNING.toString(), + THREAD_ID.toString(), + THREAD_NAME.toString(), + HTTP_STATUS_CODE_KEY.toString(), + HTTP_STATUS_CODE_KEY_DD.toString(), + ORIGIN_KEY.toString(), + PROCESS_TAGS_KEY.toString())); + private OtlpTraceProto() {} /** Records a scoped spans message after its nested span messages have been recorded. */ @@ -338,11 +364,10 @@ public void accept(Metadata metadata) { 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 win on a key clash, - // 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. + // 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 entry : metadata.getBaggage().entrySet()) { - if (TRACE_ID_HIGH_TAG.equals(entry.getKey()) || tags.containsKey(entry.getKey())) { + if (SKIPPED_BAGGAGE_KEYS.contains(entry.getKey()) || tags.containsKey(entry.getKey())) { continue; } writeTag(buf, 9, LEN_WIRE_TYPE); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java index ee4d3aca206..124692d835e 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java @@ -437,6 +437,25 @@ void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { assertEquals(asList("baggage.value"), attributeValues(parsedChild, "baggage.key")); } + @Test + void baggageDoesNotDuplicateDirectlyWrittenAttributes() throws IOException { + AgentSpan span = TRACER.startSpan("test", "op.direct"); + span.setResourceName("direct.op"); + span.setBaggageItem("resource.name", "baggage.resource"); + span.setBaggageItem("operation.name", "baggage.operation"); + span.setBaggageItem("thread.name", "baggage.thread"); + span.finish(); + + OtlpTraceJsonCollector collector = new OtlpTraceJsonCollector(); + collector.addTrace(asList((CoreSpan) span)); + Map parsed = onlySpan(collector.collectTraces()); + + // each key must appear once, with the span's own value + assertEquals(asList("direct.op"), attributeValues(parsed, "resource.name")); + assertEquals(asList("op.direct"), attributeValues(parsed, "operation.name")); + assertEquals(asList(Thread.currentThread().getName()), attributeValues(parsed, "thread.name")); + } + @Test void poisonedSpanResetsCollectorForNextTrace() throws IOException { // mid-trace exception (e.g. from a malformed span) must not leave partial state behind diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java index 45f4b95b007..0efdf8e90a9 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java @@ -777,13 +777,65 @@ void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { assertEquals("baggage.value", childAttributes.get("baggage.key")); } + @Test + void baggageDoesNotDuplicateDirectlyWrittenAttributes() throws IOException { + AgentSpan span = TRACER.startSpan("test", "op.direct"); + span.setResourceName("direct.op"); + span.setBaggageItem("resource.name", "baggage.resource"); + span.setBaggageItem("operation.name", "baggage.operation"); + span.setBaggageItem("thread.name", "baggage.thread"); + span.finish(); + + OtlpTraceProtoCollector collector = new OtlpTraceProtoCollector(); + collector.addTrace(asList((CoreSpan) span)); + List attributes = + parseStringAttributeListsBySpanName(collector.collectTraces()).get("direct.op"); + assertNotNull(attributes, "span must be exported"); + + // each key must appear once, with the span's own value + assertEquals(asList("direct.op"), stringAttributeValues(attributes, "resource.name")); + assertEquals(asList("op.direct"), stringAttributeValues(attributes, "operation.name")); + assertEquals( + asList(Thread.currentThread().getName()), stringAttributeValues(attributes, "thread.name")); + } + + /** Returns the values of every attribute with the given key, keeping duplicates. */ + private static List stringAttributeValues(List attributes, String key) { + List values = new ArrayList<>(); + for (String[] attribute : attributes) { + if (key.equals(attribute[0])) { + values.add(attribute[1]); + } + } + return values; + } + /** Maps each span name to its string-valued attributes. */ private static Map> parseStringAttributesBySpanName( OtlpPayload payload) throws IOException { + Map> result = new HashMap<>(); + parseStringAttributeListsBySpanName(payload) + .forEach( + (name, attributes) -> { + Map attributeMap = new HashMap<>(); + for (String[] attribute : attributes) { + attributeMap.put(attribute[0], attribute[1]); + } + result.put(name, attributeMap); + }); + return result; + } + + /** + * Maps each span name to its raw string-valued attributes, as {key, value} pairs in wire order, + * keeping duplicate keys. + */ + private static Map> parseStringAttributeListsBySpanName( + OtlpPayload payload) throws IOException { CodedInputStream tracesData = CodedInputStream.newInstance(payload.getContent()); tracesData.readTag(); // field 1: TracesData.resource_spans CodedInputStream resourceSpans = tracesData.readBytes().newCodedInput(); - Map> result = new HashMap<>(); + Map> result = new HashMap<>(); while (!resourceSpans.isAtEnd()) { int tag = resourceSpans.readTag(); if (WireFormat.getTagFieldNumber(tag) != 2) { @@ -799,7 +851,7 @@ private static Map> parseStringAttributesBySpanName( } CodedInputStream spanData = scopeSpans.readBytes().newCodedInput(); String name = null; - Map attributes = new HashMap<>(); + List attributes = new ArrayList<>(); while (!spanData.isAtEnd()) { int spanTag = spanData.readTag(); switch (WireFormat.getTagFieldNumber(spanTag)) { @@ -807,7 +859,10 @@ private static Map> parseStringAttributesBySpanName( name = spanData.readString(); break; case 9: - readStringKeyValue(spanData.readBytes().newCodedInput(), attributes); + String[] attribute = readStringKeyValue(spanData.readBytes().newCodedInput()); + if (attribute != null) { + attributes.add(attribute); + } break; default: spanData.skipField(spanTag); @@ -820,11 +875,10 @@ private static Map> parseStringAttributesBySpanName( } /** - * Reads a {@code KeyValue { string key = 1; AnyValue value = 2; }} into the map when its value is - * an {@code AnyValue { string string_value = 1; }}. + * Reads a {@code KeyValue { string key = 1; AnyValue value = 2; }} as {key, value} when its value + * is an {@code AnyValue { string string_value = 1; }}, otherwise returns null. */ - private static void readStringKeyValue(CodedInputStream keyValue, Map into) - throws IOException { + private static String[] readStringKeyValue(CodedInputStream keyValue) throws IOException { String key = null; String value = null; while (!keyValue.isAtEnd()) { @@ -848,9 +902,7 @@ private static void readStringKeyValue(CodedInputStream keyValue, Map Date: Fri, 23 Oct 2026 16:10:19 +0200 Subject: [PATCH 05/10] Share the OTLP span attribute keys between the trace writers Co-Authored-By: Claude Opus 5.5 --- .../core/otlp/trace/OtlpTraceAttributes.java | 69 +++++++++++++++++++ .../trace/core/otlp/trace/OtlpTraceJson.java | 57 ++------------- .../trace/core/otlp/trace/OtlpTraceProto.java | 57 ++------------- 3 files changed, 83 insertions(+), 100 deletions(-) create mode 100644 dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java new file mode 100644 index 00000000000..053154d6178 --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java @@ -0,0 +1,69 @@ +package datadog.trace.core.otlp.trace; + +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_MEASURED; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_PARTIAL_VERSION; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_TOP_LEVEL; +import static datadog.trace.bootstrap.instrumentation.api.InstrumentationTags.DD_WAS_LONG_RUNNING; +import static datadog.trace.common.writer.ddagent.TraceMapper.ORIGIN_KEY; +import static datadog.trace.common.writer.ddagent.TraceMapper.PROCESS_TAGS_KEY; +import static datadog.trace.common.writer.ddagent.TraceMapper.SAMPLING_PRIORITY_KEY; +import static datadog.trace.common.writer.ddagent.TraceMapper.THREAD_ID; +import static datadog.trace.common.writer.ddagent.TraceMapper.THREAD_NAME; + +import datadog.trace.api.KnownTagCodec; +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** Span attribute keys shared by the OTLP protobuf and JSON trace writers. */ +final class OtlpTraceAttributes { + + static final UTF8BytesString RESOURCE_NAME = UTF8BytesString.create("resource.name"); + static final UTF8BytesString OPERATION_NAME = UTF8BytesString.create("operation.name"); + static final UTF8BytesString SPAN_TYPE = UTF8BytesString.create("span.type"); + + /* + * Span fields and intercepted Metadata fields bypass TagMap entry projection. Resolve their + * keys through the registry here so OpenTelemetry names stay consistent with entry tags. + */ + static final UTF8BytesString SERVICE_NAME_KEY = otelKey(KnownTags.SERVICE_ID); + + static final UTF8BytesString HTTP_STATUS_CODE_KEY = otelKey(KnownTags.HTTP_STATUS_CODE_ID); + + static final UTF8BytesString HTTP_STATUS_CODE_KEY_DD = + UTF8BytesString.create(KnownTags.HTTP_STATUS_CODE_NAME); + + /** + * Keys that baggage must not export as attributes: the attributes the writers emit directly from + * span fields and intercepted Metadata fields (the span's own value wins), plus _dd.p.tid, whose + * upper 64 bits the OTLP traceId already carries. + */ + static final Set SKIPPED_BAGGAGE_KEYS = + new HashSet<>( + Arrays.asList( + "_dd.p.tid", + SERVICE_NAME_KEY.toString(), + RESOURCE_NAME.toString(), + OPERATION_NAME.toString(), + SPAN_TYPE.toString(), + SAMPLING_PRIORITY_KEY.toString(), + DD_MEASURED.toString(), + DD_TOP_LEVEL.toString(), + DD_PARTIAL_VERSION.toString(), + DD_WAS_LONG_RUNNING.toString(), + THREAD_ID.toString(), + THREAD_NAME.toString(), + HTTP_STATUS_CODE_KEY.toString(), + HTTP_STATUS_CODE_KEY_DD.toString(), + ORIGIN_KEY.toString(), + PROCESS_TAGS_KEY.toString())); + + /** The OpenTelemetry-namespace key for a known tag, as named by the registry. */ + private static UTF8BytesString otelKey(long tagId) { + return UTF8BytesString.create(KnownTagCodec.openTelemetryTagOf(tagId)); + } + + private OtlpTraceAttributes() {} +} 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 index 0faa2108455..b70377b5fa9 100644 --- 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 @@ -22,12 +22,18 @@ import static datadog.trace.core.otlp.common.OtlpTraceFlags.REMOTE_TRACE_FLAG; import static datadog.trace.core.otlp.common.OtlpTraceFlags.SAMPLED_TRACE_FLAG; import static datadog.trace.core.otlp.trace.OtlpSpanKind.spanKind; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.HTTP_STATUS_CODE_KEY; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.HTTP_STATUS_CODE_KEY_DD; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.OPERATION_NAME; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.RESOURCE_NAME; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SERVICE_NAME_KEY; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SKIPPED_BAGGAGE_KEYS; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SPAN_TYPE; import datadog.json.JsonWriter; import datadog.trace.api.Config; import datadog.trace.api.DDTags; import datadog.trace.api.KnownTagCodec; -import datadog.trace.api.KnownTags; import datadog.trace.api.TagMap; import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; @@ -37,61 +43,12 @@ import datadog.trace.core.PendingTrace; import datadog.trace.core.propagation.PropagationTags; import datadog.trace.core.propagation.PropagationTags.SamplingState; -import java.util.Arrays; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; /** Provides writers for OpenTelemetry's "trace.proto" JSON encoding. */ public final class OtlpTraceJson { - private static final UTF8BytesString RESOURCE_NAME = UTF8BytesString.create("resource.name"); - private static final UTF8BytesString OPERATION_NAME = UTF8BytesString.create("operation.name"); - private static final UTF8BytesString SPAN_TYPE = UTF8BytesString.create("span.type"); - - /* - * Span fields and intercepted Metadata fields bypass TagMap entry projection. Resolve their - * keys through the registry here so JSON and protobuf use the same OpenTelemetry names. - */ - private static final UTF8BytesString SERVICE_NAME_KEY = otelKey(KnownTags.SERVICE_ID); - - private static final UTF8BytesString HTTP_STATUS_CODE_KEY = - otelKey(KnownTags.HTTP_STATUS_CODE_ID); - - private static final UTF8BytesString HTTP_STATUS_CODE_KEY_DD = - UTF8BytesString.create(KnownTags.HTTP_STATUS_CODE_NAME); - - /** The OpenTelemetry-namespace key for a known tag, as named by the registry. */ - private static UTF8BytesString otelKey(long tagId) { - return UTF8BytesString.create(KnownTagCodec.openTelemetryTagOf(tagId)); - } - - /** - * Keys that baggage must not export as attributes: the attributes this writer emits directly from - * span fields and intercepted Metadata fields (the span's own value wins), plus _dd.p.tid, whose - * upper 64 bits the OTLP traceId already carries. - */ - private static final Set SKIPPED_BAGGAGE_KEYS = - new HashSet<>( - Arrays.asList( - "_dd.p.tid", - SERVICE_NAME_KEY.toString(), - RESOURCE_NAME.toString(), - OPERATION_NAME.toString(), - SPAN_TYPE.toString(), - SAMPLING_PRIORITY_KEY.toString(), - DD_MEASURED.toString(), - DD_TOP_LEVEL.toString(), - DD_PARTIAL_VERSION.toString(), - DD_WAS_LONG_RUNNING.toString(), - THREAD_ID.toString(), - THREAD_NAME.toString(), - HTTP_STATUS_CODE_KEY.toString(), - HTTP_STATUS_CODE_KEY_DD.toString(), - ORIGIN_KEY.toString(), - PROCESS_TAGS_KEY.toString())); - private OtlpTraceJson() {} /** Writes one complete {@code Span} JSON object. */ 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 index 00813dfb881..b5ae1c2d5db 100644 --- 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 @@ -31,6 +31,13 @@ import static datadog.trace.core.otlp.common.OtlpTraceFlags.REMOTE_TRACE_FLAG; import static datadog.trace.core.otlp.common.OtlpTraceFlags.SAMPLED_TRACE_FLAG; import static datadog.trace.core.otlp.trace.OtlpSpanKind.spanKind; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.HTTP_STATUS_CODE_KEY; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.HTTP_STATUS_CODE_KEY_DD; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.OPERATION_NAME; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.RESOURCE_NAME; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SERVICE_NAME_KEY; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SKIPPED_BAGGAGE_KEYS; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SPAN_TYPE; import static java.nio.charset.StandardCharsets.UTF_8; import datadog.communication.serialization.GrowableBuffer; @@ -39,7 +46,6 @@ import datadog.trace.api.DDTags; import datadog.trace.api.DDTraceId; import datadog.trace.api.KnownTagCodec; -import datadog.trace.api.KnownTags; import datadog.trace.api.TagMap; import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; @@ -51,60 +57,11 @@ import datadog.trace.core.otlp.common.OtlpProtoBuffer; import datadog.trace.core.propagation.PropagationTags; import datadog.trace.core.propagation.PropagationTags.SamplingState; -import java.util.Arrays; -import java.util.HashSet; import java.util.Map; -import java.util.Set; /** Provides optimized writers for OpenTelemetry's "trace.proto" wire protocol. */ public final class OtlpTraceProto { - private static final UTF8BytesString RESOURCE_NAME = UTF8BytesString.create("resource.name"); - private static final UTF8BytesString OPERATION_NAME = UTF8BytesString.create("operation.name"); - private static final UTF8BytesString SPAN_TYPE = UTF8BytesString.create("span.type"); - - /* - * Span fields and intercepted Metadata fields bypass TagMap entry projection. Resolve their - * keys through the registry here so OpenTelemetry names stay consistent with entry tags. - */ - private static final UTF8BytesString SERVICE_NAME_KEY = otelKey(KnownTags.SERVICE_ID); - - private static final UTF8BytesString HTTP_STATUS_CODE_KEY = - otelKey(KnownTags.HTTP_STATUS_CODE_ID); - - private static final UTF8BytesString HTTP_STATUS_CODE_KEY_DD = - UTF8BytesString.create(KnownTags.HTTP_STATUS_CODE_NAME); - - /** The OpenTelemetry-namespace key for a known tag, as named by the registry. */ - private static UTF8BytesString otelKey(long tagId) { - return UTF8BytesString.create(KnownTagCodec.openTelemetryTagOf(tagId)); - } - - /** - * Keys that baggage must not export as attributes: the attributes this writer emits directly from - * span fields and intercepted Metadata fields (the span's own value wins), plus _dd.p.tid, whose - * upper 64 bits the OTLP traceId already carries. - */ - private static final Set SKIPPED_BAGGAGE_KEYS = - new HashSet<>( - Arrays.asList( - "_dd.p.tid", - SERVICE_NAME_KEY.toString(), - RESOURCE_NAME.toString(), - OPERATION_NAME.toString(), - SPAN_TYPE.toString(), - SAMPLING_PRIORITY_KEY.toString(), - DD_MEASURED.toString(), - DD_TOP_LEVEL.toString(), - DD_PARTIAL_VERSION.toString(), - DD_WAS_LONG_RUNNING.toString(), - THREAD_ID.toString(), - THREAD_NAME.toString(), - HTTP_STATUS_CODE_KEY.toString(), - HTTP_STATUS_CODE_KEY_DD.toString(), - ORIGIN_KEY.toString(), - PROCESS_TAGS_KEY.toString())); - private OtlpTraceProto() {} /** Records a scoped spans message after its nested span messages have been recorded. */ From ee1fa5bb576fc14b9f451dc61d368014e5e34b86 Mon Sep 17 00:00:00 2001 From: Anna Date: Mon, 26 Oct 2026 15:00:39 +0100 Subject: [PATCH 06/10] Export propagation tags on partial flushes without the local root Co-Authored-By: Claude Opus 5.5 --- .../trace/core/otlp/trace/OtlpTraceJson.java | 6 +- .../trace/core/otlp/trace/OtlpTraceProto.java | 6 +- .../trace/OtlpTraceJsonCollectorTest.java | 119 +++++++++++--- .../core/otlp/trace/OtlpTraceProtoTest.java | 146 ++++++++++++------ 4 files changed, 209 insertions(+), 68 deletions(-) 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 index b70377b5fa9..5f3f4e958d9 100644 --- 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 @@ -98,10 +98,10 @@ public static void writeSpan( if (span.getSpanType() != null) { writeSpanTag(writer, SPAN_TYPE, span.getSpanType()); } - // 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. + // 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, span.getLocalRootSpan() == span ? ROOT_PROPAGATION_TAGS : 0); + metaWriter, metaWriter.includeSamplingTags ? ROOT_PROPAGATION_TAGS : 0); writer.endArray(); if (!links.isEmpty()) { 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 index b5ae1c2d5db..5e052bf1971 100644 --- 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 @@ -146,10 +146,10 @@ public static int recordSpanMessage( writeSpanTag(buf, SPAN_TYPE, span.getSpanType()); } - // 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. + // 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, span.getLocalRootSpan() == span ? ROOT_PROPAGATION_TAGS : 0); + metaWriter, metaWriter.includeSamplingTags ? ROOT_PROPAGATION_TAGS : 0); if (span.isError()) { int stateSize = 2; diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java index 124692d835e..ba84dabae7d 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java @@ -398,9 +398,74 @@ void multipleSpansInATraceAreAllWritten() throws IOException { } @Test - void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { + void propagationTagsAreWrittenOncePerTraceOnTheSpanSelectedForSamplingTags() throws IOException { + AgentSpan root = startRootWithPropagationTags("root.op"); + AgentSpan child = startChild(root, "child.op"); + child.finish(); + root.finish(); + + OtlpTraceJsonCollector collector = new OtlpTraceJsonCollector(); + collector.addTrace(asList((CoreSpan) root, (CoreSpan) child)); + List> parsedSpans = allSpans(collector.collectTraces()); + + // the collector selects the last exported span of each trace + assertEquals(asList("root.op", "child.op"), spanNames(parsedSpans)); + assertPropagationTagsOnlyOn(parsedSpans, "child.op", "root.op", "child.op"); + } + + @Test + void partialFlushWithoutTheLocalRootStillCarriesPropagationTags() throws IOException { + AgentSpan root = startRootWithPropagationTags("root.op"); + AgentSpan first = startChild(root, "first.child"); + AgentSpan second = startChild(root, "second.child"); + first.finish(); + second.finish(); + + // a partial flush exports finished children while the local root is still open + OtlpTraceJsonCollector collector = new OtlpTraceJsonCollector(); + collector.addTrace(asList((CoreSpan) first, (CoreSpan) second)); + List> parsedSpans = allSpans(collector.collectTraces()); + root.finish(); + + assertEquals(asList("first.child", "second.child"), spanNames(parsedSpans)); + assertPropagationTagsOnlyOn(parsedSpans, "second.child", "first.child", "second.child"); + } + + @Test + void propagationTagsAreWrittenOnceForEachTraceInThePayload() throws IOException { + AgentSpan rootA = startRootWithPropagationTags("a.root"); + AgentSpan childA = startChild(rootA, "a.child"); + AgentSpan rootB = startRootWithPropagationTags("b.root"); + AgentSpan firstB = startChild(rootB, "b.first"); + AgentSpan secondB = startChild(rootB, "b.second"); + AgentSpan rootC = startRootWithPropagationTags("c.root"); + assertNotEquals(rootA.getTraceId(), rootB.getTraceId(), "trace IDs must be distinct"); + assertNotEquals(rootB.getTraceId(), rootC.getTraceId(), "trace IDs must be distinct"); + childA.finish(); + rootA.finish(); + firstB.finish(); + secondB.finish(); + rootC.finish(); + + OtlpTraceJsonCollector collector = new OtlpTraceJsonCollector(); + collector.addTrace(asList((CoreSpan) rootA, (CoreSpan) childA)); + // partial flush of trace B, without its local root + collector.addTrace(asList((CoreSpan) firstB, (CoreSpan) secondB)); + collector.addTrace(asList((CoreSpan) rootC)); + List> parsedSpans = allSpans(collector.collectTraces()); + rootB.finish(); + + assertEquals( + asList("a.root", "a.child", "b.first", "b.second", "c.root"), spanNames(parsedSpans)); + assertPropagationTagsOnlyOn(parsedSpans, "a.child", "a.root", "a.child"); + assertPropagationTagsOnlyOn(parsedSpans, "b.second", "b.first", "b.second"); + assertPropagationTagsOnlyOn(parsedSpans, "c.root", "c.root"); + } + + /** Starts a local root span with a probability sampling decision and a baggage item. */ + private static AgentSpan startRootWithPropagationTags(String resourceName) { AgentSpan root = TRACER.startSpan("test", "op.root"); - root.setResourceName("root.op"); + root.setResourceName(resourceName); // 128-bit trace ids are on by default, so the root carries a _dd.p.tid propagation tag assertNotEquals(0, root.getTraceId().toHighOrderLong()); ((DDSpan) root) @@ -414,27 +479,43 @@ void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { root.getTraceId().toLong(), true); root.setBaggageItem("baggage.key", "baggage.value"); - AgentSpan child = TRACER.startSpan("test", "op.child", root.spanContext()); - child.setResourceName("child.op"); - child.finish(); - root.finish(); + return root; + } - OtlpTraceJsonCollector collector = new OtlpTraceJsonCollector(); - collector.addTrace(asList((CoreSpan) root, (CoreSpan) child)); - List> parsedSpans = allSpans(collector.collectTraces()); + private static AgentSpan startChild(AgentSpan parent, String resourceName) { + AgentSpan child = TRACER.startSpan("test", "op.child", parent.spanContext()); + child.setResourceName(resourceName); + return child; + } - Map parsedRoot = spanNamed(parsedSpans, "root.op"); - assertEquals(asList("-12"), attributeValues(parsedRoot, "_dd.p.dm")); - assertEquals(asList("0.5"), attributeValues(parsedRoot, "_dd.p.ksr")); - // the OTLP traceId already carries the upper 64 bits - assertFalse(attributeKeys(parsedRoot).contains("_dd.p.tid"), "_dd.p.tid must not be exported"); - assertEquals(asList("baggage.value"), attributeValues(parsedRoot, "baggage.key")); + private static List spanNames(List> spans) { + List names = new ArrayList<>(); + for (Map span : spans) { + names.add(span.get("name")); + } + return names; + } - Map parsedChild = spanNamed(parsedSpans, "child.op"); - for (String key : attributeKeys(parsedChild)) { - assertFalse(key.startsWith("_dd.p."), "child span must not carry " + key); + /** + * Asserts that, among the exported spans of one trace, only {@code carrier} has the root + * propagation tags (once each), and that every span keeps its baggage. + */ + private static void assertPropagationTagsOnlyOn( + List> spans, String carrier, String... traceSpans) { + for (String spanName : traceSpans) { + Map span = spanNamed(spans, spanName); + if (spanName.equals(carrier)) { + assertEquals(asList("-12"), attributeValues(span, "_dd.p.dm"), spanName); + assertEquals(asList("0.5"), attributeValues(span, "_dd.p.ksr"), spanName); + // the OTLP traceId already carries the upper 64 bits + assertFalse(attributeKeys(span).contains("_dd.p.tid"), "_dd.p.tid must not be exported"); + } else { + for (String key : attributeKeys(span)) { + assertFalse(key.startsWith("_dd.p."), spanName + " must not carry " + key); + } + } + assertEquals(asList("baggage.value"), attributeValues(span, "baggage.key"), spanName); } - assertEquals(asList("baggage.value"), attributeValues(parsedChild, "baggage.key")); } @Test diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java index 0efdf8e90a9..311fb8bcd7f 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java @@ -46,6 +46,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -735,9 +736,76 @@ void poisonedSpanResetsCollectorForNextTrace() { } @Test - void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { + void propagationTagsAreWrittenOncePerTraceOnTheSpanSelectedForSamplingTags() throws IOException { + AgentSpan root = startRootWithPropagationTags("root.op"); + AgentSpan child = startChild(root, "child.op"); + child.finish(); + root.finish(); + + OtlpTraceProtoCollector collector = new OtlpTraceProtoCollector(); + collector.addTrace(asList((CoreSpan) root, (CoreSpan) child)); + Map> attributesBySpan = + parseStringAttributeListsBySpanName(collector.collectTraces()); + + // the payload buffer is prepending, so the collector selects the first exported span + assertEquals(asList("root.op", "child.op"), new ArrayList<>(attributesBySpan.keySet())); + assertPropagationTagsOnlyOn(attributesBySpan, "root.op", "root.op", "child.op"); + } + + @Test + void partialFlushWithoutTheLocalRootStillCarriesPropagationTags() throws IOException { + AgentSpan root = startRootWithPropagationTags("root.op"); + AgentSpan first = startChild(root, "first.child"); + AgentSpan second = startChild(root, "second.child"); + first.finish(); + second.finish(); + + // a partial flush exports finished children while the local root is still open + OtlpTraceProtoCollector collector = new OtlpTraceProtoCollector(); + collector.addTrace(asList((CoreSpan) first, (CoreSpan) second)); + Map> attributesBySpan = + parseStringAttributeListsBySpanName(collector.collectTraces()); + root.finish(); + + assertEquals(asList("first.child", "second.child"), new ArrayList<>(attributesBySpan.keySet())); + assertPropagationTagsOnlyOn(attributesBySpan, "first.child", "first.child", "second.child"); + } + + @Test + void propagationTagsAreWrittenOnceForEachTraceInThePayload() throws IOException { + AgentSpan rootA = startRootWithPropagationTags("a.root"); + AgentSpan childA = startChild(rootA, "a.child"); + AgentSpan rootB = startRootWithPropagationTags("b.root"); + AgentSpan firstB = startChild(rootB, "b.first"); + AgentSpan secondB = startChild(rootB, "b.second"); + AgentSpan rootC = startRootWithPropagationTags("c.root"); + assertNotEquals(rootA.getTraceId(), rootB.getTraceId(), "trace IDs must be distinct"); + assertNotEquals(rootB.getTraceId(), rootC.getTraceId(), "trace IDs must be distinct"); + childA.finish(); + rootA.finish(); + firstB.finish(); + secondB.finish(); + rootC.finish(); + + OtlpTraceProtoCollector collector = new OtlpTraceProtoCollector(); + collector.addTrace(asList((CoreSpan) rootA, (CoreSpan) childA)); + // partial flush of trace B, without its local root + collector.addTrace(asList((CoreSpan) firstB, (CoreSpan) secondB)); + collector.addTrace(asList((CoreSpan) rootC)); + Map> attributesBySpan = + parseStringAttributeListsBySpanName(collector.collectTraces()); + rootB.finish(); + + assertEquals(5, attributesBySpan.size(), "all spans must be exported"); + assertPropagationTagsOnlyOn(attributesBySpan, "a.root", "a.root", "a.child"); + assertPropagationTagsOnlyOn(attributesBySpan, "b.first", "b.first", "b.second"); + assertPropagationTagsOnlyOn(attributesBySpan, "c.root", "c.root"); + } + + /** Starts a local root span with a probability sampling decision and a baggage item. */ + private static AgentSpan startRootWithPropagationTags(String resourceName) { AgentSpan root = TRACER.startSpan("test", "op.root"); - root.setResourceName("root.op"); + root.setResourceName(resourceName); // 128-bit trace ids are on by default, so the root carries a _dd.p.tid propagation tag assertNotEquals(0, root.getTraceId().toHighOrderLong()); ((DDSpan) root) @@ -751,30 +819,38 @@ void propagationTagsAreWrittenOnTheLocalRootSpanOnly() throws IOException { root.getTraceId().toLong(), true); root.setBaggageItem("baggage.key", "baggage.value"); - AgentSpan child = TRACER.startSpan("test", "op.child", root.spanContext()); - child.setResourceName("child.op"); - child.finish(); - root.finish(); + return root; + } - OtlpTraceProtoCollector collector = new OtlpTraceProtoCollector(); - collector.addTrace(asList((CoreSpan) root, (CoreSpan) child)); - Map> attributesBySpan = - parseStringAttributesBySpanName(collector.collectTraces()); - - Map rootAttributes = attributesBySpan.get("root.op"); - assertNotNull(rootAttributes, "root span must be exported"); - assertEquals("-11", rootAttributes.get("_dd.p.dm")); - assertEquals("0.5", rootAttributes.get("_dd.p.ksr")); - // the OTLP traceId already carries the upper 64 bits - assertFalse(rootAttributes.containsKey("_dd.p.tid"), "_dd.p.tid must not be exported"); - assertEquals("baggage.value", rootAttributes.get("baggage.key")); - - Map childAttributes = attributesBySpan.get("child.op"); - assertNotNull(childAttributes, "child span must be exported"); - for (String key : childAttributes.keySet()) { - assertFalse(key.startsWith("_dd.p."), "child span must not carry " + key); + private static AgentSpan startChild(AgentSpan parent, String resourceName) { + AgentSpan child = TRACER.startSpan("test", "op.child", parent.spanContext()); + child.setResourceName(resourceName); + return child; + } + + /** + * Asserts that, among the exported spans of one trace, only {@code carrier} has the root + * propagation tags (once each), and that every span keeps its baggage. + */ + private static void assertPropagationTagsOnlyOn( + Map> attributesBySpan, String carrier, String... traceSpans) { + for (String spanName : traceSpans) { + List attributes = attributesBySpan.get(spanName); + assertNotNull(attributes, spanName + " must be exported"); + if (spanName.equals(carrier)) { + assertEquals(asList("-11"), stringAttributeValues(attributes, "_dd.p.dm"), spanName); + assertEquals(asList("0.5"), stringAttributeValues(attributes, "_dd.p.ksr"), spanName); + // the OTLP traceId already carries the upper 64 bits + assertEquals( + emptyList(), stringAttributeValues(attributes, "_dd.p.tid"), "_dd.p.tid not exported"); + } else { + for (String[] attribute : attributes) { + assertFalse( + attribute[0].startsWith("_dd.p."), spanName + " must not carry " + attribute[0]); + } + } + assertEquals(asList("baggage.value"), stringAttributeValues(attributes, "baggage.key")); } - assertEquals("baggage.value", childAttributes.get("baggage.key")); } @Test @@ -810,32 +886,16 @@ private static List stringAttributeValues(List attributes, Str return values; } - /** Maps each span name to its string-valued attributes. */ - private static Map> parseStringAttributesBySpanName( - OtlpPayload payload) throws IOException { - Map> result = new HashMap<>(); - parseStringAttributeListsBySpanName(payload) - .forEach( - (name, attributes) -> { - Map attributeMap = new HashMap<>(); - for (String[] attribute : attributes) { - attributeMap.put(attribute[0], attribute[1]); - } - result.put(name, attributeMap); - }); - return result; - } - /** - * Maps each span name to its raw string-valued attributes, as {key, value} pairs in wire order, - * keeping duplicate keys. + * Maps each span name, in wire order, to its raw string-valued attributes, as {key, value} pairs + * keeping their order and duplicate keys. */ private static Map> parseStringAttributeListsBySpanName( OtlpPayload payload) throws IOException { CodedInputStream tracesData = CodedInputStream.newInstance(payload.getContent()); tracesData.readTag(); // field 1: TracesData.resource_spans CodedInputStream resourceSpans = tracesData.readBytes().newCodedInput(); - Map> result = new HashMap<>(); + Map> result = new LinkedHashMap<>(); while (!resourceSpans.isAtEnd()) { int tag = resourceSpans.readTag(); if (WireFormat.getTagFieldNumber(tag) != 2) { From 6dfd8eedde3aa2ab038e9b2de5f46b55d95b1f79 Mon Sep 17 00:00:00 2001 From: Anna Date: Mon, 26 Oct 2026 15:58:36 +0100 Subject: [PATCH 07/10] Don't let baggage duplicate direction-dependent OTLP attribute names Co-Authored-By: Claude Opus 5.5 --- .../trace/core/otlp/trace/OtlpTraceJson.java | 15 ++++-- .../trace/core/otlp/trace/OtlpTraceProto.java | 15 ++++-- .../trace/OtlpTraceJsonCollectorTest.java | 45 +++++++++++++++++ .../core/otlp/trace/OtlpTraceProtoTest.java | 50 +++++++++++++++++++ 4 files changed, 117 insertions(+), 8 deletions(-) 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 index 5f3f4e958d9..99a72dbe291 100644 --- 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 @@ -241,19 +241,26 @@ public void accept(Metadata metadata) { } 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. + // written directly above win on a key clash, which keeps OTLP attribute keys unique. Under + // OTel semantics some tags are exported under a direction-dependent name (peer.port is + // server.port on a client span), so the baggage key is also resolved for this direction. for (Map.Entry 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; } 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); // reset for next span 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 index 5e052bf1971..56d072e7f65 100644 --- 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 @@ -319,20 +319,27 @@ public void accept(Metadata metadata) { } 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. + // written directly above win on a key clash, which keeps OTLP attribute keys unique. Under + // OTel semantics some tags are exported under a direction-dependent name (peer.port is + // server.port on a client span), so the baggage key is also resolved for this direction. for (Map.Entry 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; } writeTag(buf, 9, LEN_WIRE_TYPE); writeAttribute(buf, 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(buf, direction, OtlpTraceProto::writeSpanTag); // reset for next span diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java index ba84dabae7d..d02efa21e12 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java @@ -537,6 +537,51 @@ void baggageDoesNotDuplicateDirectlyWrittenAttributes() throws IOException { assertEquals(asList(Thread.currentThread().getName()), attributeValues(parsed, "thread.name")); } + @TableTest({ + "scenario | kind | tag | otelName ", + "client hostname | client | peer.hostname | server.address ", + "server hostname | server | http.hostname | server.address ", + "server client ip | server | network.client.ip | network.peer.address" + }) + void baggageDoesNotDuplicateDirectionScopedAttributes(String kind, String tag, String otelName) + throws IOException { + Map parsed = exportWithDirectionalBaggage(true, kind, tag, otelName); + + // the tag is exported under its direction-specific name, once, with the span's own value + assertEquals(asList("span.value"), attributeValues(parsed, otelName)); + assertTrue(attributeValues(parsed, tag).isEmpty(), "expected no " + tag); + } + + @Test + void baggageIsKeptWhenOtelSemanticsDisabled() throws IOException { + // without the rename the tag keeps its Datadog name, so the baggage key is distinct + Map parsed = + exportWithDirectionalBaggage(false, "client", "peer.hostname", "server.address"); + + assertEquals(asList("span.value"), attributeValues(parsed, "peer.hostname")); + assertEquals(asList("baggage.value"), attributeValues(parsed, "server.address")); + } + + private static Map exportWithDirectionalBaggage( + boolean otelSemanticsEnabled, String kind, String tag, String baggageKey) throws IOException { + Config realConfig = Config.get(); + try (MockedStatic configMock = mockStatic(Config.class)) { + Config config = mock(Config.class, AdditionalAnswers.delegatesTo(realConfig)); + when(config.isTraceOtelSemanticsEnabled()).thenReturn(otelSemanticsEnabled); + configMock.when(Config::get).thenReturn(config); + + AgentSpan span = TRACER.startSpan("test", "op.directionalBaggage"); + span.setTag(SPAN_KIND, kind); + span.setTag(tag, "span.value"); + span.setBaggageItem(baggageKey, "baggage.value"); + span.finish(); + + OtlpTraceJsonCollector collector = new OtlpTraceJsonCollector(); + collector.addTrace(asList((CoreSpan) span)); + return onlySpan(collector.collectTraces()); + } + } + @Test void poisonedSpanResetsCollectorForNextTrace() throws IOException { // mid-trace exception (e.g. from a malformed span) must not leave partial state behind diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java index 311fb8bcd7f..8bbe6bc63f2 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java @@ -54,6 +54,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.AdditionalAnswers; import org.mockito.MockedStatic; @@ -875,6 +876,55 @@ void baggageDoesNotDuplicateDirectlyWrittenAttributes() throws IOException { asList(Thread.currentThread().getName()), stringAttributeValues(attributes, "thread.name")); } + @ParameterizedTest(name = "{0} {1} -> {2}") + @CsvSource({ + "client, peer.hostname, server.address", + "server, http.hostname, server.address", + "server, network.client.ip, network.peer.address" + }) + void baggageDoesNotDuplicateDirectionScopedAttributes(String kind, String tag, String otelName) + throws IOException { + List attributes = exportWithDirectionalBaggage(true, kind, tag, otelName); + + // the tag is exported under its direction-specific name, once, with the span's own value + assertEquals(asList("span.value"), stringAttributeValues(attributes, otelName)); + assertTrue(stringAttributeValues(attributes, tag).isEmpty(), "expected no " + tag); + } + + @Test + void baggageIsKeptWhenOtelSemanticsDisabled() throws IOException { + // without the rename the tag keeps its Datadog name, so the baggage key is distinct + List attributes = + exportWithDirectionalBaggage(false, "client", "peer.hostname", "server.address"); + + assertEquals(asList("span.value"), stringAttributeValues(attributes, "peer.hostname")); + assertEquals(asList("baggage.value"), stringAttributeValues(attributes, "server.address")); + } + + private static List exportWithDirectionalBaggage( + boolean otelSemanticsEnabled, String kind, String tag, String baggageKey) throws IOException { + Config realConfig = Config.get(); + try (MockedStatic configMock = mockStatic(Config.class)) { + Config config = mock(Config.class, AdditionalAnswers.delegatesTo(realConfig)); + when(config.isTraceOtelSemanticsEnabled()).thenReturn(otelSemanticsEnabled); + configMock.when(Config::get).thenReturn(config); + + AgentSpan span = TRACER.startSpan("test", "op.directionalBaggage"); + span.setResourceName("directional.baggage"); + span.setTag(SPAN_KIND, kind); + span.setTag(tag, "span.value"); + span.setBaggageItem(baggageKey, "baggage.value"); + span.finish(); + + OtlpTraceProtoCollector collector = new OtlpTraceProtoCollector(); + collector.addTrace(asList((CoreSpan) span)); + List attributes = + parseStringAttributeListsBySpanName(collector.collectTraces()).get("directional.baggage"); + assertNotNull(attributes, "span must be exported"); + return attributes; + } + } + /** Returns the values of every attribute with the given key, keeping duplicates. */ private static List stringAttributeValues(List attributes, String key) { List values = new ArrayList<>(); From 721290583b8ed671673a04a20f0f332e1bd75bb1 Mon Sep 17 00:00:00 2001 From: Anna Date: Tue, 27 Oct 2026 16:11:15 +0100 Subject: [PATCH 08/10] Skip baggage only when a span tag is exported under the same OTLP key Co-Authored-By: Claude Opus 5.5 --- .../core/otlp/trace/OtlpTraceAttributes.java | 25 ++++++++++++++++++ .../trace/core/otlp/trace/OtlpTraceJson.java | 11 +++----- .../trace/core/otlp/trace/OtlpTraceProto.java | 11 +++----- .../trace/OtlpTraceJsonCollectorTest.java | 26 +++++++++++++++++++ .../core/otlp/trace/OtlpTraceProtoTest.java | 24 +++++++++++++++++ 5 files changed, 83 insertions(+), 14 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java index 053154d6178..297e726740a 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java @@ -12,6 +12,7 @@ import datadog.trace.api.KnownTagCodec; import datadog.trace.api.KnownTags; +import datadog.trace.api.TagMap; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import java.util.Arrays; import java.util.HashSet; @@ -60,6 +61,30 @@ final class OtlpTraceAttributes { ORIGIN_KEY.toString(), PROCESS_TAGS_KEY.toString())); + /** + * True if a span tag is exported under exactly {@code key}, so baggage under that key would + * duplicate it. TagMap lookups resolve aliases (http.request.method finds http.method), so the + * matching entry's exported name is compared with the key: it depends on OTel semantics and, for + * direction-scoped tags, on the span's direction (peer.port is server.port on a client). + */ + static boolean isExportedTagKey(TagMap tags, String key, int direction, boolean otelSemantics) { + if (exportsAs(tags.getEntry(key), key, direction, otelSemantics)) { + return true; + } + if (otelSemantics) { + long directionalTagId = KnownTagCodec.directionalKeyOf(key, direction); + return directionalTagId != 0 + && exportsAs(tags.getEntry(directionalTagId), key, direction, true); + } + return false; + } + + private static boolean exportsAs( + TagMap.Entry entry, String key, int direction, boolean otelSemantics) { + return entry != null + && key.equals(otelSemantics ? entry.openTelemetryTag(direction) : entry.tag()); + } + /** The OpenTelemetry-namespace key for a known tag, as named by the registry. */ private static UTF8BytesString otelKey(long tagId) { return UTF8BytesString.create(KnownTagCodec.openTelemetryTagOf(tagId)); 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 index 99a72dbe291..51d819371c2 100644 --- 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 @@ -29,6 +29,7 @@ import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SERVICE_NAME_KEY; import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SKIPPED_BAGGAGE_KEYS; import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SPAN_TYPE; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.isExportedTagKey; import datadog.json.JsonWriter; import datadog.trace.api.Config; @@ -247,15 +248,11 @@ public void accept(Metadata metadata) { // 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. Under - // OTel semantics some tags are exported under a direction-dependent name (peer.port is - // server.port on a client span), so the baggage key is also resolved for this direction. + // written directly above win when they are exported under the same key, which keeps OTLP + // attribute keys unique without dropping baggage whose exported key differs. for (Map.Entry 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)) { + || isExportedTagKey(tags, entry.getKey(), direction, otelSemantics)) { continue; } writeAttribute(writer, STRING_ATTRIBUTE, entry.getKey(), entry.getValue()); 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 index 56d072e7f65..e1065afcbd2 100644 --- 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 @@ -38,6 +38,7 @@ import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SERVICE_NAME_KEY; import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SKIPPED_BAGGAGE_KEYS; import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SPAN_TYPE; +import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.isExportedTagKey; import static java.nio.charset.StandardCharsets.UTF_8; import datadog.communication.serialization.GrowableBuffer; @@ -325,15 +326,11 @@ public void accept(Metadata metadata) { // 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. Under - // OTel semantics some tags are exported under a direction-dependent name (peer.port is - // server.port on a client span), so the baggage key is also resolved for this direction. + // written directly above win when they are exported under the same key, which keeps OTLP + // attribute keys unique without dropping baggage whose exported key differs. for (Map.Entry 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)) { + || isExportedTagKey(tags, entry.getKey(), direction, otelSemantics)) { continue; } writeTag(buf, 9, LEN_WIRE_TYPE); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java index d02efa21e12..0585dae3fec 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java @@ -562,6 +562,32 @@ void baggageIsKeptWhenOtelSemanticsDisabled() throws IOException { assertEquals(asList("baggage.value"), attributeValues(parsed, "server.address")); } + @TableTest({ + "scenario | otelSemantics | baggageKey | httpMethod |" + + " httpRequestMethod", + "otel off, otel-name key | false | http.request.method | span.value |" + + " baggage.value ", + "otel on, otel-name key | true | http.request.method | | span.value " + + " ", + "otel on, datadog-name key | true | http.method | baggage.value | span.value " + + " " + }) + void baggageIsSkippedOnlyWhenATagIsExportedUnderTheSameKey( + boolean otelSemantics, String baggageKey, String httpMethod, String httpRequestMethod) + throws IOException { + // http.method and http.request.method are aliases of one tag: lookups by either name find it, + // but only one of them is exported, so baggage under the other name must be kept + Map parsed = + exportWithDirectionalBaggage(otelSemantics, "server", "http.method", baggageKey); + + assertEquals(expectedValues(httpMethod), attributeValues(parsed, "http.method")); + assertEquals(expectedValues(httpRequestMethod), attributeValues(parsed, "http.request.method")); + } + + private static List expectedValues(String value) { + return value == null || value.isEmpty() ? new ArrayList<>() : asList((Object) value); + } + private static Map exportWithDirectionalBaggage( boolean otelSemanticsEnabled, String kind, String tag, String baggageKey) throws IOException { Config realConfig = Config.get(); diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java index 8bbe6bc63f2..0c32799fec6 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceProtoTest.java @@ -901,6 +901,30 @@ void baggageIsKeptWhenOtelSemanticsDisabled() throws IOException { assertEquals(asList("baggage.value"), stringAttributeValues(attributes, "server.address")); } + @ParameterizedTest(name = "otel semantics {0}, baggage {1}") + @CsvSource({ + "false, http.request.method, span.value, baggage.value", + "true, http.request.method, '', span.value", + "true, http.method, baggage.value, span.value" + }) + void baggageIsSkippedOnlyWhenATagIsExportedUnderTheSameKey( + boolean otelSemantics, String baggageKey, String httpMethod, String httpRequestMethod) + throws IOException { + // http.method and http.request.method are aliases of one tag: lookups by either name find it, + // but only one of them is exported, so baggage under the other name must be kept + List attributes = + exportWithDirectionalBaggage(otelSemantics, "server", "http.method", baggageKey); + + assertEquals(expectedValues(httpMethod), stringAttributeValues(attributes, "http.method")); + assertEquals( + expectedValues(httpRequestMethod), + stringAttributeValues(attributes, "http.request.method")); + } + + private static List expectedValues(String value) { + return value == null || value.isEmpty() ? new ArrayList<>() : asList(value); + } + private static List exportWithDirectionalBaggage( boolean otelSemanticsEnabled, String kind, String tag, String baggageKey) throws IOException { Config realConfig = Config.get(); From 359e585b8fa352469a8f88171d93dec906841caf Mon Sep 17 00:00:00 2001 From: Anna Date: Tue, 27 Oct 2026 16:22:27 +0100 Subject: [PATCH 09/10] Keep the alias TableTest rows on one line for spotless Co-Authored-By: Claude Opus 5.5 --- .../core/otlp/trace/OtlpTraceJsonCollectorTest.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java index 0585dae3fec..da87345927d 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/otlp/trace/OtlpTraceJsonCollectorTest.java @@ -563,14 +563,10 @@ void baggageIsKeptWhenOtelSemanticsDisabled() throws IOException { } @TableTest({ - "scenario | otelSemantics | baggageKey | httpMethod |" - + " httpRequestMethod", - "otel off, otel-name key | false | http.request.method | span.value |" - + " baggage.value ", - "otel on, otel-name key | true | http.request.method | | span.value " - + " ", - "otel on, datadog-name key | true | http.method | baggage.value | span.value " - + " " + "scenario | otelSemantics | baggageKey | httpMethod | httpRequestMethod", + "off, otel name | false | http.request.method | span.value | baggage.value ", + "on, otel name | true | http.request.method | | span.value ", + "on, dd name | true | http.method | baggage.value | span.value " }) void baggageIsSkippedOnlyWhenATagIsExportedUnderTheSameKey( boolean otelSemantics, String baggageKey, String httpMethod, String httpRequestMethod) From 8c0d93d8ea4d1202d6ffb5fed944b1f37f08893d Mon Sep 17 00:00:00 2001 From: Anna Date: Wed, 28 Oct 2026 13:38:24 +0100 Subject: [PATCH 10/10] Rename OtlpTraceAttributes to OtlpSpanAttributes Co-Authored-By: Claude Opus 5.5 --- ...ceAttributes.java => OtlpSpanAttributes.java} | 4 ++-- .../trace/core/otlp/trace/OtlpTraceJson.java | 16 ++++++++-------- .../trace/core/otlp/trace/OtlpTraceProto.java | 16 ++++++++-------- 3 files changed, 18 insertions(+), 18 deletions(-) rename dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/{OtlpTraceAttributes.java => OtlpSpanAttributes.java} (98%) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpSpanAttributes.java similarity index 98% rename from dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java rename to dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpSpanAttributes.java index 297e726740a..75e0cc33be4 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpTraceAttributes.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/trace/OtlpSpanAttributes.java @@ -19,7 +19,7 @@ import java.util.Set; /** Span attribute keys shared by the OTLP protobuf and JSON trace writers. */ -final class OtlpTraceAttributes { +final class OtlpSpanAttributes { static final UTF8BytesString RESOURCE_NAME = UTF8BytesString.create("resource.name"); static final UTF8BytesString OPERATION_NAME = UTF8BytesString.create("operation.name"); @@ -90,5 +90,5 @@ private static UTF8BytesString otelKey(long tagId) { return UTF8BytesString.create(KnownTagCodec.openTelemetryTagOf(tagId)); } - private OtlpTraceAttributes() {} + private OtlpSpanAttributes() {} } 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 index 51d819371c2..68c80100d61 100644 --- 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 @@ -21,15 +21,15 @@ import static datadog.trace.core.otlp.common.OtlpTraceFlags.NO_TRACE_FLAGS; import static datadog.trace.core.otlp.common.OtlpTraceFlags.REMOTE_TRACE_FLAG; import static datadog.trace.core.otlp.common.OtlpTraceFlags.SAMPLED_TRACE_FLAG; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.HTTP_STATUS_CODE_KEY; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.HTTP_STATUS_CODE_KEY_DD; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.OPERATION_NAME; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.RESOURCE_NAME; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.SERVICE_NAME_KEY; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.SKIPPED_BAGGAGE_KEYS; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.SPAN_TYPE; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.isExportedTagKey; import static datadog.trace.core.otlp.trace.OtlpSpanKind.spanKind; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.HTTP_STATUS_CODE_KEY; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.HTTP_STATUS_CODE_KEY_DD; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.OPERATION_NAME; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.RESOURCE_NAME; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SERVICE_NAME_KEY; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SKIPPED_BAGGAGE_KEYS; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SPAN_TYPE; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.isExportedTagKey; import datadog.json.JsonWriter; import datadog.trace.api.Config; 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 index e1065afcbd2..865411dbe03 100644 --- 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 @@ -30,15 +30,15 @@ import static datadog.trace.core.otlp.common.OtlpTraceFlags.NO_TRACE_FLAGS; import static datadog.trace.core.otlp.common.OtlpTraceFlags.REMOTE_TRACE_FLAG; import static datadog.trace.core.otlp.common.OtlpTraceFlags.SAMPLED_TRACE_FLAG; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.HTTP_STATUS_CODE_KEY; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.HTTP_STATUS_CODE_KEY_DD; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.OPERATION_NAME; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.RESOURCE_NAME; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.SERVICE_NAME_KEY; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.SKIPPED_BAGGAGE_KEYS; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.SPAN_TYPE; +import static datadog.trace.core.otlp.trace.OtlpSpanAttributes.isExportedTagKey; import static datadog.trace.core.otlp.trace.OtlpSpanKind.spanKind; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.HTTP_STATUS_CODE_KEY; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.HTTP_STATUS_CODE_KEY_DD; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.OPERATION_NAME; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.RESOURCE_NAME; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SERVICE_NAME_KEY; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SKIPPED_BAGGAGE_KEYS; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.SPAN_TYPE; -import static datadog.trace.core.otlp.trace.OtlpTraceAttributes.isExportedTagKey; import static java.nio.charset.StandardCharsets.UTF_8; import datadog.communication.serialization.GrowableBuffer;