From 09fc0d9dfcc65e3763c6877d075d6e322e1f9948 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 20 Oct 2026 09:33:24 -0400 Subject: [PATCH 1/2] Expose the trace's global tags and span defaults on Metadata Serializers get the global tags (DD_TAGS, or their remote-config override) and the span defaults from the trace's config snapshot, so an OTLP export can write the global tags once as resource attributes instead of on every span. Both come from the snapshot the trace started with, so a config change mid-trace can't split one trace across two sets. getTags() is unchanged and still includes both. Test call sites pass empty maps; PayloadDispatcherImplTest's mocked PendingTrace now returns a real snapshot. Co-Authored-By: Claude Opus 5.5 --- .../datadog/trace/core/DDSpanContext.java | 3 +++ .../java/datadog/trace/core/Metadata.java | 23 +++++++++++++++++++ .../FileBasedPayloadDispatcherTest.java | 2 ++ .../writer/PayloadDispatcherImplTest.java | 9 ++++++++ .../trace/common/writer/TraceGenerator.java | 2 ++ .../java/datadog/trace/core/MetadataTest.java | 2 ++ .../traceAgentTest/java/TraceGenerator.java | 2 ++ 7 files changed, 43 insertions(+) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java index 9281c377afb..5f3ab56b602 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java @@ -1529,11 +1529,14 @@ void processTagsAndBaggage( : serializedPropagationTags.createTagMap(); } + CoreTracer.ConfigSnapshot traceConfig = traceCollector.getTraceConfig(); consumer.accept( new Metadata( threadId, threadName, unsafeTags, + traceConfig.globalTags, + traceConfig.spanDefaultTags, baggageItemsWithPropagationTags, getSamplingPriority(), measured, diff --git a/dd-trace-core/src/main/java/datadog/trace/core/Metadata.java b/dd-trace-core/src/main/java/datadog/trace/core/Metadata.java index d10187d1861..3bf2a3fe9b5 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/Metadata.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/Metadata.java @@ -14,6 +14,8 @@ public final class Metadata { private final UTF8BytesString threadName; private final int httpStatusCode; private final TagMap tags; + private final TagMap globalTags; + private final TagMap spanDefaultTags; private final Map baggage; private final int samplingPriority; @@ -30,6 +32,8 @@ public Metadata( long threadId, UTF8BytesString threadName, TagMap tags, + TagMap globalTags, + TagMap spanDefaultTags, Map baggage, int samplingPriority, boolean measured, @@ -45,6 +49,8 @@ public Metadata( this.threadName = threadName; this.httpStatusCode = httpStatusCode; this.tags = tags; + this.globalTags = globalTags; + this.spanDefaultTags = spanDefaultTags; this.baggage = baggage; this.samplingPriority = samplingPriority; this.measured = measured; @@ -98,6 +104,23 @@ public TagMap getTags() { return this.tags; } + /** + * The trace's global tags ({@code DD_TAGS}, or their remote-config override). {@link #getTags()} + * already includes them, below the span defaults and the span's own tags; this lets a serializer + * that writes them once per payload, such as an OTLP resource, tell them apart. + */ + public TagMap getGlobalTags() { + return globalTags; + } + + /** + * The trace's span defaults ({@code DD_SPAN_TAGS} and tracer-defined tags such as {@code env}), + * without the global tags. {@link #getTags()} already includes them, below the span's own tags. + */ + public TagMap getSpanDefaultTags() { + return spanDefaultTags; + } + public Map getBaggage() { return baggage; } diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/FileBasedPayloadDispatcherTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/FileBasedPayloadDispatcherTest.java index 2704dff3d3e..f116a1589f0 100644 --- a/dd-trace-core/src/test/java/datadog/trace/common/writer/FileBasedPayloadDispatcherTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/FileBasedPayloadDispatcherTest.java @@ -307,6 +307,8 @@ private static CoreSpan mockSpan(CharSequence type, Map tags) Thread.currentThread().getId(), null, TagMap.fromMap(tags), + TagMap.EMPTY, + TagMap.EMPTY, Collections.emptyMap(), 0, false, diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java index cf756003e7c..44467412346 100644 --- a/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/PayloadDispatcherImplTest.java @@ -181,6 +181,12 @@ void testTraceAndSpanCountsAreResetAfterAccess() { } DDSpan realSpan() throws Exception { + // A real config snapshot, since serialization reads the trace's tag levels from it. Its type + // isn't visible from this package, so it's held as an Object and matched by name below. + CoreTracer tracer = CoreTracer.builder().writer(new ListWriter()).build(); + Object traceConfig = tracer.captureTraceConfig(); + tracer.close(); + // getTracer() and mapServiceName() are package-private in TraceCollector; use a custom // Answer to handle them at runtime without compile-time accessibility issues PendingTrace trace = @@ -193,6 +199,9 @@ DDSpan realSpan() throws Exception { // behavior) return mock(CoreTracer.class); } + if (returnType.getSimpleName().equals("ConfigSnapshot")) { + return traceConfig; + } if (returnType == String.class) { Object[] args = invocation.getArguments(); // mapServiceName(String) - return the argument unchanged diff --git a/dd-trace-core/src/test/java/datadog/trace/common/writer/TraceGenerator.java b/dd-trace-core/src/test/java/datadog/trace/common/writer/TraceGenerator.java index c570b2ae7e4..c4ea0720df3 100644 --- a/dd-trace-core/src/test/java/datadog/trace/common/writer/TraceGenerator.java +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/TraceGenerator.java @@ -238,6 +238,8 @@ public PojoSpan( Thread.currentThread().getId(), UTF8BytesString.create(Thread.currentThread().getName()), TagMap.fromMap(tags), + TagMap.EMPTY, + TagMap.EMPTY, baggage, samplingPriority, measured, diff --git a/dd-trace-core/src/test/java/datadog/trace/core/MetadataTest.java b/dd-trace-core/src/test/java/datadog/trace/core/MetadataTest.java index bbc5c607233..d844c42ea54 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/MetadataTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/MetadataTest.java @@ -54,6 +54,8 @@ private static Metadata metadataWithStatus(int status) { Thread.currentThread().getId(), UTF8BytesString.create("main"), TagMap.fromMap(emptyMap()), + TagMap.EMPTY, + TagMap.EMPTY, emptyMap(), 0, false, diff --git a/dd-trace-core/src/traceAgentTest/java/TraceGenerator.java b/dd-trace-core/src/traceAgentTest/java/TraceGenerator.java index 0fe52affee4..1050191cff4 100644 --- a/dd-trace-core/src/traceAgentTest/java/TraceGenerator.java +++ b/dd-trace-core/src/traceAgentTest/java/TraceGenerator.java @@ -175,6 +175,8 @@ static class PojoSpan implements CoreSpan { currentThread().getId(), UTF8BytesString.create(currentThread().getName()), fromMap(tags), + TagMap.EMPTY, + TagMap.EMPTY, baggage, UNSET, measured, From 709a142d91436ea4ba750c0def8fbc848579f4a9 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 20 Oct 2026 09:33:27 -0400 Subject: [PATCH 2/2] Test that Metadata carries the trace's tag levels Co-Authored-By: Claude Opus 5.5 --- .../datadog/trace/core/CoreTracerTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/dd-trace-core/src/test/java/datadog/trace/core/CoreTracerTest.java b/dd-trace-core/src/test/java/datadog/trace/core/CoreTracerTest.java index 1143fa11a97..a891203838f 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/CoreTracerTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/CoreTracerTest.java @@ -6,7 +6,9 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.mockito.ArgumentMatchers.any; @@ -722,6 +724,39 @@ void configSnapshotSeparatesGlobalTagsFromSpanDefaults() throws Exception { } } + /** + * Serializers get the global tags and span defaults of the snapshot the trace started with, so a + * config change mid-trace can't split one trace across two sets. + */ + @Test + void metadataCarriesTheTraceConfigTagLevels() { + Properties properties = new Properties(); + properties.setProperty(GeneralConfig.TAGS, "global_key:global_val"); + properties.setProperty(TracerConfig.SPAN_TAGS, "span_key:span_val"); + CoreTracer tracer = CoreTracer.builder().withProperties(properties).build(); + unclosedTracers.add(tracer); + + try { + ConfigSnapshot traceStartConfig = tracer.captureTraceConfig(); + DDSpan span = (DDSpan) tracer.buildSpan("datadog", "my_span").start(); + + Properties changed = new Properties(); + changed.setProperty(GeneralConfig.TAGS, "other_key:other_val"); + tracer.rebuildTraceConfig(Config.get(changed)); + assertNotSame(traceStartConfig.globalTags, tracer.captureTraceConfig().globalTags); + + Metadata[] captured = {null}; + span.processTagsAndBaggage(metadata -> captured[0] = metadata, 0); + + assertSame(traceStartConfig.globalTags, captured[0].getGlobalTags()); + assertSame(traceStartConfig.spanDefaultTags, captured[0].getSpanDefaultTags()); + assertEquals(buildStringMap("global_key", "global_val"), captured[0].getGlobalTags()); + assertEquals(buildStringMap("span_key", "span_val"), captured[0].getSpanDefaultTags()); + } finally { + tracer.close(); + } + } + static final String ACTION_JSON = "'{\"action\": \"enable\", \"lib_config\":" + "{\"tracing_sampling_rate\": null,"