From 6fec4d9d65194b6ef908622d290920d111333e1f Mon Sep 17 00:00:00 2001 From: Daniel Mohedano Date: Wed, 13 May 2026 16:14:02 +0200 Subject: [PATCH 1/3] feat: propagation of custom tags to all test event levels --- .../domain/AbstractTestModule.java | 2 +- .../domain/AbstractTestSession.java | 2 +- .../domain/SpanTagsPropagator.java | 36 ++++++++++++ .../civisibility/domain/TestSuiteImpl.java | 9 ++- .../buildsystem/BuildSystemModuleImpl.java | 1 + .../domain/buildsystem/ProxyTestModule.java | 23 +++++++- .../ipc/ModuleExecutionResult.java | 22 ++++++-- .../domain/SpanTagsPropagatorTest.groovy | 56 +++++++++++++++++++ .../headless/HeadlessTestSessionTest.groovy | 12 +++- .../domain/manualapi/ManualApiTest.groovy | 16 +++++- .../ipc/ModuleExecutionResultTest.groovy | 10 ++-- .../civisibility/ipc/SignalServerTest.groovy | 14 ++--- .../trace/api/config/CiVisibilityConfig.java | 1 + .../main/java/datadog/trace/api/Config.java | 8 +++ metadata/supported-configurations.json | 8 +++ 15 files changed, 194 insertions(+), 26 deletions(-) diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/AbstractTestModule.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/AbstractTestModule.java index 6dfc5137337..f0d2adcf08f 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/AbstractTestModule.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/AbstractTestModule.java @@ -64,7 +64,7 @@ public AbstractTestModule( } span = spanBuilder.start(); - tagsPropagator = new SpanTagsPropagator(span); + tagsPropagator = new SpanTagsPropagator(span, config.getCiVisibilityPropagatedTagKeys()); span.setSpanType(InternalSpanTypes.TEST_MODULE_END); span.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_TEST_MODULE); diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/AbstractTestSession.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/AbstractTestSession.java index cb17381f420..a979218d072 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/AbstractTestSession.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/AbstractTestSession.java @@ -99,7 +99,7 @@ public AbstractTestSession( } span = spanBuilder.start(); - tagPropagator = new SpanTagsPropagator(span); + tagPropagator = new SpanTagsPropagator(span, config.getCiVisibilityPropagatedTagKeys()); span.setSpanType(InternalSpanTypes.TEST_SESSION_END); span.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_TEST_SESSION); diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/SpanTagsPropagator.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/SpanTagsPropagator.java index 8002b235356..0615b913975 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/SpanTagsPropagator.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/SpanTagsPropagator.java @@ -18,15 +18,23 @@ public class SpanTagsPropagator { public static final Consumer NOOP_PROPAGATOR = span -> {}; private final AgentSpan parentSpan; + private final Collection propagatedTagKeys; private final Object tagPropagationLock = new Object(); public SpanTagsPropagator(AgentSpan parentSpan) { + this(parentSpan, Collections.emptyList()); + } + + public SpanTagsPropagator(AgentSpan parentSpan, Collection propagatedTagKeys) { this.parentSpan = parentSpan; + this.propagatedTagKeys = + propagatedTagKeys != null ? propagatedTagKeys : Collections.emptyList(); } public void propagateCiVisibilityTags(AgentSpan childSpan) { mergeTestFrameworks(getFrameworks(childSpan)); propagateStatus(childSpan); + propagateCustomTags(childSpan); } public void propagateStatus(AgentSpan childSpan) { @@ -49,6 +57,34 @@ public void propagateTags(AgentSpan childSpan, TagMergeSpec... specs) { } } + public void propagateCustomTags(AgentSpan childSpan) { + if (propagatedTagKeys.isEmpty()) { + return; + } + synchronized (tagPropagationLock) { + for (String key : propagatedTagKeys) { + Object value = childSpan.getTag(key); + if (value != null) { + parentSpan.setTag(key, String.valueOf(value)); + } + } + } + } + + public void propagateCustomTags(Map tags) { + if (propagatedTagKeys.isEmpty() || tags == null || tags.isEmpty()) { + return; + } + synchronized (tagPropagationLock) { + for (String key : propagatedTagKeys) { + String value = tags.get(key); + if (value != null) { + parentSpan.setTag(key, value); + } + } + } + } + private void unsafeMergeTestFrameworks(Collection childFrameworks) { Collection parentFrameworks = getFrameworks(parentSpan); Collection merged = merge(parentFrameworks, childFrameworks); diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/TestSuiteImpl.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/TestSuiteImpl.java index 3c4a6dc4242..a567ee8fe70 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/TestSuiteImpl.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/TestSuiteImpl.java @@ -112,7 +112,7 @@ public TestSuiteImpl( } span = spanBuilder.start(); - tagsPropagator = new SpanTagsPropagator(span); + tagsPropagator = new SpanTagsPropagator(span, config.getCiVisibilityPropagatedTagKeys()); span.setSpanType(InternalSpanTypes.TEST_SUITE_END); span.setTag(Tags.SPAN_KIND, Tags.SPAN_KIND_TEST_SUITE); @@ -275,6 +275,11 @@ public TestImpl testStart( executionResults, configurationErrors, capabilities, - tagsPropagator::propagateStatus); + this::propagateTags); + } + + private void propagateTags(AgentSpan childSpan) { + tagsPropagator.propagateStatus(childSpan); + tagsPropagator.propagateCustomTags(childSpan); } } diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/BuildSystemModuleImpl.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/BuildSystemModuleImpl.java index 48915cdb479..cca411ef1a6 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/BuildSystemModuleImpl.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/BuildSystemModuleImpl.java @@ -290,6 +290,7 @@ private SignalResponse onModuleExecutionResultReceived(ModuleExecutionResult res testsSkipped.add(result.getTestsSkippedTotal()); tagsPropagator.mergeTestFrameworks(result.getTestFrameworks()); + tagsPropagator.propagateCustomTags(result.getPropagatedTags()); return AckResponse.INSTANCE; } diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/ProxyTestModule.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/ProxyTestModule.java index 41107bbe923..ef0272315c9 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/ProxyTestModule.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/ProxyTestModule.java @@ -31,6 +31,8 @@ import datadog.trace.civisibility.test.ExecutionResults; import datadog.trace.civisibility.test.ExecutionStrategy; import java.util.Collection; +import java.util.Map; +import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import javax.annotation.Nonnull; @@ -61,6 +63,8 @@ public class ProxyTestModule implements TestFrameworkModule { private final LinesResolver linesResolver; private final CoverageStore.Factory coverageStoreFactory; private final Collection testFrameworks = ConcurrentHashMap.newKeySet(); + private final Map propagatedTags = new ConcurrentHashMap<>(); + private final Set propagatedTagKeys; private final Collection capabilities; public ProxyTestModule( @@ -91,6 +95,7 @@ public ProxyTestModule( this.linesResolver = linesResolver; this.coverageStoreFactory = coverageStoreFactory; this.capabilities = capabilities; + this.propagatedTagKeys = config.getCiVisibilityPropagatedTagKeys(); } @Override @@ -180,7 +185,8 @@ private void sendModuleExecutionResult() { testManagementEnabled, hasFailedTestReplayTests, testsSkippedTotal, - new TreeSet<>(testFrameworks))); + new TreeSet<>(testFrameworks), + propagatedTags)); } catch (Exception e) { log.error("Error while reporting module execution result", e); @@ -215,13 +221,24 @@ public TestSuiteImpl testSuiteStart( executionResults, executionStrategy.getExecutionSettings().getConfigurationErrors(), capabilities, - this::propagateTestFrameworkData); + this::propagateData); } - private void propagateTestFrameworkData(AgentSpan childSpan) { + private void propagateData(AgentSpan childSpan) { testFrameworks.add( new TestFramework( (String) childSpan.getTag(Tags.TEST_FRAMEWORK), (String) childSpan.getTag(Tags.TEST_FRAMEWORK_VERSION))); + + if (propagatedTagKeys.isEmpty()) { + return; + } + + for (String key : propagatedTagKeys) { + Object value = childSpan.getTag(key); + if (value != null) { + propagatedTags.put(key, String.valueOf(value)); + } + } } } diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/ModuleExecutionResult.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/ModuleExecutionResult.java index 2f3508c366f..da9df04f740 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/ModuleExecutionResult.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/ModuleExecutionResult.java @@ -4,6 +4,8 @@ import datadog.trace.civisibility.ipc.serialization.Serializer; import java.nio.ByteBuffer; import java.util.Collection; +import java.util.Collections; +import java.util.Map; import java.util.Objects; public class ModuleExecutionResult extends ModuleSignal { @@ -23,6 +25,7 @@ public class ModuleExecutionResult extends ModuleSignal { private final boolean hasFailedTestReplayTests; private final long testsSkippedTotal; private final Collection testFrameworks; + private final Map propagatedTags; public ModuleExecutionResult( DDTraceId sessionId, @@ -34,7 +37,8 @@ public ModuleExecutionResult( boolean testManagementEnabled, boolean hasFailedTestReplayTests, long testsSkippedTotal, - Collection testFrameworks) { + Collection testFrameworks, + Map propagatedTags) { super(sessionId, moduleId); this.coverageEnabled = coverageEnabled; this.testSkippingEnabled = testSkippingEnabled; @@ -44,6 +48,7 @@ public ModuleExecutionResult( this.hasFailedTestReplayTests = hasFailedTestReplayTests; this.testsSkippedTotal = testsSkippedTotal; this.testFrameworks = testFrameworks; + this.propagatedTags = propagatedTags != null ? propagatedTags : Collections.emptyMap(); } public boolean isCoverageEnabled() { @@ -78,6 +83,10 @@ public Collection getTestFrameworks() { return testFrameworks; } + public Map getPropagatedTags() { + return propagatedTags; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -94,7 +103,8 @@ public boolean equals(Object o) { && testSkippingEnabled == that.testSkippingEnabled && hasFailedTestReplayTests == that.hasFailedTestReplayTests && testsSkippedTotal == that.testsSkippedTotal - && Objects.equals(testFrameworks, that.testFrameworks); + && Objects.equals(testFrameworks, that.testFrameworks) + && Objects.equals(propagatedTags, that.propagatedTags); } @Override @@ -106,7 +116,8 @@ public int hashCode() { testSkippingEnabled, hasFailedTestReplayTests, testsSkippedTotal, - testFrameworks); + testFrameworks, + propagatedTags); } @Override @@ -161,6 +172,7 @@ public ByteBuffer serialize() { s.write(testsSkippedTotal); s.write(testFrameworks, TestFramework::serialize); + s.write(propagatedTags); return s.flush(); } @@ -180,6 +192,7 @@ public static ModuleExecutionResult deserialize(ByteBuffer buffer) { long testsSkippedTotal = Serializer.readLong(buffer); Collection testFrameworks = Serializer.readList(buffer, TestFramework::deserialize); + Map propagatedTags = Serializer.readStringMap(buffer); return new ModuleExecutionResult( sessionId, @@ -191,6 +204,7 @@ public static ModuleExecutionResult deserialize(ByteBuffer buffer) { testManagementEnabled, hasFailedTestReplayTests, testsSkippedTotal, - testFrameworks); + testFrameworks, + propagatedTags); } } diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.groovy index f55cc33a753..bbae04283c5 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.groovy @@ -3,6 +3,7 @@ package datadog.trace.civisibility.domain import static datadog.trace.civisibility.domain.SpanTagsPropagator.TagMergeSpec import datadog.trace.api.civisibility.execution.TestStatus +import datadog.trace.bootstrap.instrumentation.api.AgentSpan import datadog.trace.bootstrap.instrumentation.api.Tags import datadog.trace.civisibility.ipc.TestFramework import datadog.trace.core.DDSpan @@ -126,6 +127,61 @@ class SpanTagsPropagatorTest extends Specification { TagMergeSpec.of("tag", Boolean::logicalOr) | false | false | true | false } + // Mocks AgentSpan (interface) rather than DDSpan because propagateCustomTags writes through + // the final DDSpan#setTag(String, String) overload, which Spock cannot intercept on a class mock. + def "test custom tag propagation from span: child=#childValue, parent=#parentValue, key=#key, allowlist=#allowlist"() { + given: + def parentSpan = Mock(AgentSpan) + parentSpan.getTag(key) >> parentValue + + def childSpan = Mock(AgentSpan) + childSpan.getTag(key) >> childValue + + def propagator = new SpanTagsPropagator(parentSpan, allowlist) + + when: + propagator.propagateCustomTags(childSpan) + + then: + if (expectedValue != null) { + 1 * parentSpan.setTag(key, expectedValue) + } else { + 0 * parentSpan.setTag(key, _) + } + + where: + allowlist | key | childValue | parentValue | expectedValue + ["bazel.shard_index"] | "bazel.shard_index" | "0" | null | "0" + ["bazel.shard_index"] | "bazel.shard_index" | "1" | "0" | "1" // child overrides parent + ["bazel.shard_index"] | "bazel.shard_index" | null | "0" | null // missing on child, no-op + ["bazel.shard_index"] | "bazel.total_shards" | "2" | null | null // not in allowlist + [] | "bazel.shard_index" | "0" | null | null // empty allowlist + null | "bazel.shard_index" | "0" | null | null // null allowlist + ["bazel.shard_index"] | "bazel.shard_index" | 0L | null | "0" // non-string child stringified + ["bazel.shard_index"] | "bazel.shard_index" | true | null | "true" // boolean stringified + } + + def "test custom tag propagation from map: allowlist=#allowlist, tags=#tags"() { + given: + def parentSpan = Mock(AgentSpan) + def propagator = new SpanTagsPropagator(parentSpan, allowlist) + + when: + propagator.propagateCustomTags(tags) + + then: + expectedSets * parentSpan.setTag(_, _) + + where: + allowlist | tags | expectedSets + ["bazel.shard_index", "bazel.total_shards"] | ["bazel.shard_index": "0", "bazel.total_shards": "2"] | 2 + ["bazel.shard_index"] | ["bazel.shard_index": "0"] | 1 + ["bazel.shard_index"] | ["bazel.shard_index": "0", "bazel.total_shards": "2"] | 1 // only allowlisted keys are copied + ["bazel.shard_index"] | [:] | 0 // empty tags + [] | ["bazel.shard_index": "0"] | 0 // empty allowlist + null | ["bazel.shard_index": "0"] | 0 // null allowlist + } + def "test synchronized propagation"() { given: def parentSpan = Mock(DDSpan) diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/headless/HeadlessTestSessionTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/headless/HeadlessTestSessionTest.groovy index 2b0ddfeeadb..399c3477500 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/headless/HeadlessTestSessionTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/headless/HeadlessTestSessionTest.groovy @@ -24,6 +24,7 @@ class HeadlessTestSessionTest extends SpanWriterTest { def module = session.testModuleStart("module-name", null) when: + module.setTag("custom.propagated_tag", "value") module.end(null) session.end(null) @@ -34,10 +35,14 @@ class HeadlessTestSessionTest extends SpanWriterTest { spanType DDSpanTypes.TEST_SESSION_END tags(false) { "$Tags.TEST_TEST_MANAGEMENT_ENABLED" true + "custom.propagated_tag" "value" } } span(1) { spanType DDSpanTypes.TEST_MODULE_END + tags(false) { + "custom.propagated_tag" "value" + } } } }) @@ -47,13 +52,16 @@ class HeadlessTestSessionTest extends SpanWriterTest { def executionSettings = Stub(ExecutionSettings) executionSettings.getTestManagementSettings() >> new TestManagementSettings(true, 10) - def executionStrategy = new ExecutionStrategy(Stub(Config), executionSettings, Stub(SourcePathResolver), Stub(LinesResolver)) + def config = Stub(Config) + config.getCiVisibilityPropagatedTagKeys() >> ["custom.propagated_tag"] + + def executionStrategy = new ExecutionStrategy(config, executionSettings, Stub(SourcePathResolver), Stub(LinesResolver)) new HeadlessTestSession( "project-name", null, Provider.UNSUPPORTED, - Stub(Config), + config, Stub(CiVisibilityMetricCollector), Stub(TestDecorator), Stub(SourcePathResolver), diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/manualapi/ManualApiTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/manualapi/ManualApiTest.groovy index 51962ea5704..ef42e2f573a 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/manualapi/ManualApiTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/manualapi/ManualApiTest.groovy @@ -23,6 +23,8 @@ class ManualApiTest extends SpanWriterTest { def test = suite.testStart("test-name", null, null) when: + test.setTag("custom.tag", "something") + test.setTag("custom.another_tag", 2) test.end(null) suite.end(null) module.end(null) @@ -47,14 +49,26 @@ class ManualApiTest extends SpanWriterTest { moduleSpan.tags[Tags.TEST_FRAMEWORK] == component suiteSpan.tags[Tags.TEST_FRAMEWORK] == component testSpan.tags[Tags.TEST_FRAMEWORK] == component + sessionSpan.tags["custom.tag"] == "something" + sessionSpan.tags["custom.another_tag"] == "2" + sessionSpan.tags["custom.third_tag"] == null + moduleSpan.tags["custom.tag"] == "something" + moduleSpan.tags["custom.another_tag"] == "2" + moduleSpan.tags["custom.third_tag"] == null + suiteSpan.tags["custom.tag"] == "something" + suiteSpan.tags["custom.another_tag"] == "2" + suiteSpan.tags["custom.third_tag"] == null } private ManualApiTestSession givenAManualApiSession(String component) { + def config = Stub(Config) + config.getCiVisibilityPropagatedTagKeys() >> ["custom.tag", "custom.another_tag", "custom.third_tag"] + new ManualApiTestSession( "project-name", null, Provider.UNSUPPORTED, - Stub(Config), + config, Stub(CiVisibilityMetricCollector), new TestDecoratorImpl(component, "session-name", "test-command", [:]), Stub(SourcePathResolver), diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/ModuleExecutionResultTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/ModuleExecutionResultTest.groovy index 08d58ba8f84..f8762b9cb52 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/ModuleExecutionResultTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/ModuleExecutionResultTest.groovy @@ -16,11 +16,11 @@ class ModuleExecutionResultTest extends Specification { where: signal << [ - new ModuleExecutionResult(DDTraceId.from(12345), 67890, false, false, false, false, false, false, 0, Collections.emptyList()), - new ModuleExecutionResult(DDTraceId.from(12345), 67890, true, false, true, true, true, true, 1, Collections.singletonList(new TestFramework("junit", "4.13.2"))), - new ModuleExecutionResult(DDTraceId.from(12345), 67890, false, true, true, false, false, true, 2, Arrays.asList(new TestFramework("junit", "4.13.2"), new TestFramework("junit", "5.9.2"))), - new ModuleExecutionResult(DD128bTraceId.from(12345, 67890), 67890, false, false, false, true, true, false, 3, Arrays.asList(new TestFramework("junit", null), new TestFramework("junit", "5.9.2"))), - new ModuleExecutionResult(DD128bTraceId.from(12345, 67890), 67890, true, true, true, true, true, true, Integer.MAX_VALUE, Arrays.asList(new TestFramework("junit", "4.13.2"), new TestFramework(null, "5.9.2"))) + new ModuleExecutionResult(DDTraceId.from(12345), 67890, false, false, false, false, false, false, 0, Collections.emptyList(), Collections.emptyMap()), + new ModuleExecutionResult(DDTraceId.from(12345), 67890, true, false, true, true, true, true, 1, Collections.singletonList(new TestFramework("junit", "4.13.2")), Collections.emptyMap()), + new ModuleExecutionResult(DDTraceId.from(12345), 67890, false, true, true, false, false, true, 2, Arrays.asList(new TestFramework("junit", "4.13.2"), new TestFramework("junit", "5.9.2")), ["bazel.shard_index": "0", "bazel.total_shards": "2"]), + new ModuleExecutionResult(DD128bTraceId.from(12345, 67890), 67890, false, false, false, true, true, false, 3, Arrays.asList(new TestFramework("junit", null), new TestFramework("junit", "5.9.2")), Collections.emptyMap()), + new ModuleExecutionResult(DD128bTraceId.from(12345, 67890), 67890, true, true, true, true, true, true, Integer.MAX_VALUE, Arrays.asList(new TestFramework("junit", "4.13.2"), new TestFramework(null, "5.9.2")), ["custom.tier": "gold", "custom.region": "us-east-1"]) ] } } diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/SignalServerTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/SignalServerTest.groovy index 99331516948..7137e22ac12 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/SignalServerTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/SignalServerTest.groovy @@ -12,7 +12,7 @@ class SignalServerTest extends Specification { def "test message send and receive"() { given: def signalProcessed = new AtomicBoolean(false) - def signal = new ModuleExecutionResult(DDTraceId.from(123), 456, true, true, false, false, false, true, 1, Collections.singletonList(new TestFramework("junit", "4.13.2"))) + def signal = new ModuleExecutionResult(DDTraceId.from(123), 456, true, true, false, false, false, true, 1, Collections.singletonList(new TestFramework("junit", "4.13.2")), ["tag_a": "1", "custom.tag_b": "value"]) def server = new SignalServer() def received = new ArrayList() @@ -41,8 +41,8 @@ class SignalServerTest extends Specification { def "test multiple messages send and receive"() { given: - def signalA = new ModuleExecutionResult(DDTraceId.from(123), 456, false, false, false, false, false, false, 0, Collections.singletonList(new TestFramework("junit", "4.13.2"))) - def signalB = new ModuleExecutionResult(DDTraceId.from(234), 567, true, true, false, false, true, false, 1, Collections.singletonList(new TestFramework("junit", "4.13.2"))) + def signalA = new ModuleExecutionResult(DDTraceId.from(123), 456, false, false, false, false, false, false, 0, Collections.singletonList(new TestFramework("junit", "4.13.2")), Collections.emptyMap()) + def signalB = new ModuleExecutionResult(DDTraceId.from(234), 567, true, true, false, false, true, false, 1, Collections.singletonList(new TestFramework("junit", "4.13.2")), Collections.emptyMap()) def server = new SignalServer() def received = new ArrayList() @@ -70,8 +70,8 @@ class SignalServerTest extends Specification { def "test multiple clients send and receive"() { given: - def signalA = new ModuleExecutionResult(DDTraceId.from(123), 456, true, false, true, false, true, false, 1, Collections.singletonList(new TestFramework("junit", "4.13.2"))) - def signalB = new ModuleExecutionResult(DDTraceId.from(234), 567, false, true, false, true, false, false, 0, Collections.singletonList(new TestFramework("junit", "4.13.2"))) + def signalA = new ModuleExecutionResult(DDTraceId.from(123), 456, true, false, true, false, true, false, 1, Collections.singletonList(new TestFramework("junit", "4.13.2")), Collections.emptyMap()) + def signalB = new ModuleExecutionResult(DDTraceId.from(234), 567, false, true, false, true, false, false, 0, Collections.singletonList(new TestFramework("junit", "4.13.2")), Collections.emptyMap()) def server = new SignalServer() def received = new ArrayList() @@ -118,7 +118,7 @@ class SignalServerTest extends Specification { when: def address = server.getAddress() try (def client = new SignalClient(address, clientTimeoutMillis)) { - client.send(new ModuleExecutionResult(DDTraceId.from(123), 456, false, false, false, false, false, false, 0, Collections.singletonList(new TestFramework("junit", "4.13.2")))) + client.send(new ModuleExecutionResult(DDTraceId.from(123), 456, false, false, false, false, false, false, 0, Collections.singletonList(new TestFramework("junit", "4.13.2")), Collections.emptyMap())) } then: @@ -130,7 +130,7 @@ class SignalServerTest extends Specification { def "test error response receipt"() { given: - def signal = new ModuleExecutionResult(DDTraceId.from(123), 456, true, true, false, false, true, false, 1, Collections.singletonList(new TestFramework("junit", "4.13.2"))) + def signal = new ModuleExecutionResult(DDTraceId.from(123), 456, true, true, false, false, true, false, 1, Collections.singletonList(new TestFramework("junit", "4.13.2")), Collections.emptyMap()) def server = new SignalServer() def errorResponse = new ErrorResponse("An error occurred while processing the signal") diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/CiVisibilityConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/CiVisibilityConfig.java index 05a1075f57d..198cd5752f8 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/config/CiVisibilityConfig.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/CiVisibilityConfig.java @@ -83,6 +83,7 @@ public final class CiVisibilityConfig { public static final String TEST_MANAGEMENT_ATTEMPT_TO_FIX_RETRIES = "test.management.attempt.to.fix.retries"; public static final String TEST_FAILED_TEST_REPLAY_ENABLED = "test.failed.test.replay.enabled"; + public static final String CIVISIBILITY_PROPAGATED_TAGS = "civisibility.propagated.tags"; /* Git PR info */ public static final String GIT_PULL_REQUEST_BASE_BRANCH = "git.pull.request.base.branch"; diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index a463887f61a..8babb9fa4b3 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -280,6 +280,7 @@ import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_JVM_INFO_CACHE_SIZE; import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_KNOWN_TESTS_REQUEST_ENABLED; import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_MODULE_NAME; +import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_PROPAGATED_TAGS; import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_REPO_INDEX_DUPLICATE_KEY_CHECK_ENABLED; import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_REPO_INDEX_FOLLOW_SYMLINKS; import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_RESOURCE_FOLDER_NAMES; @@ -1169,6 +1170,7 @@ public static String getHostName() { private final String gitPullRequestBaseBranchSha; private final String gitCommitHeadSha; private final boolean ciVisibilityFailedTestReplayEnabled; + private final Set ciVisibilityPropagatedTagKeys; private final String testOptimizationManifestFile; private final boolean testOptimizationPayloadsInFiles; @@ -2708,6 +2710,8 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) gitCommitHeadSha = configProvider.getString(GIT_COMMIT_HEAD_SHA); ciVisibilityFailedTestReplayEnabled = configProvider.getBoolean(TEST_FAILED_TEST_REPLAY_ENABLED, true); + ciVisibilityPropagatedTagKeys = + configProvider.getSet(CIVISIBILITY_PROPAGATED_TAGS, Collections.emptySet()); testOptimizationManifestFile = configProvider.getString(TEST_OPTIMIZATION_MANIFEST_FILE); testOptimizationPayloadsInFiles = @@ -4489,6 +4493,10 @@ public boolean isCiVisibilityFailedTestReplayEnabled() { return ciVisibilityFailedTestReplayEnabled; } + public Set getCiVisibilityPropagatedTagKeys() { + return ciVisibilityPropagatedTagKeys; + } + public String getTestOptimizationManifestFile() { return testOptimizationManifestFile; } diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 8db93e05399..e9b8527786f 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -777,6 +777,14 @@ "aliases": [] } ], + "DD_CIVISIBILITY_PROPAGATED_TAGS": [ + { + "version": "A", + "type": "string", + "default": null, + "aliases": [] + } + ], "DD_CIVISIBILITY_REMOTE_ENV_VARS_PROVIDER_KEY": [ { "version": "A", From eca6ec917e0cba63f10fdd96b007caf7ea8595d1 Mon Sep 17 00:00:00 2001 From: Daniel Mohedano Date: Thu, 14 May 2026 16:04:26 +0200 Subject: [PATCH 2/3] feat: migrate SpanTagsPropagatorTest to JUnit5 --- .../domain/SpanTagsPropagatorTest.groovy | 231 ------------ .../domain/SpanTagsPropagatorTest.java | 331 ++++++++++++++++++ 2 files changed, 331 insertions(+), 231 deletions(-) delete mode 100644 dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.groovy create mode 100644 dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.java diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.groovy deleted file mode 100644 index bbae04283c5..00000000000 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.groovy +++ /dev/null @@ -1,231 +0,0 @@ -package datadog.trace.civisibility.domain - -import static datadog.trace.civisibility.domain.SpanTagsPropagator.TagMergeSpec - -import datadog.trace.api.civisibility.execution.TestStatus -import datadog.trace.bootstrap.instrumentation.api.AgentSpan -import datadog.trace.bootstrap.instrumentation.api.Tags -import datadog.trace.civisibility.ipc.TestFramework -import datadog.trace.core.DDSpan -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import spock.lang.Specification - -class SpanTagsPropagatorTest extends Specification { - def "test getFrameworks"() { - when: - def span = Stub(DDSpan) - span.getTag(Tags.TEST_FRAMEWORK) >> frameworkTag - span.getTag(Tags.TEST_FRAMEWORK_VERSION) >> frameworkVersionTag - - def frameworks = SpanTagsPropagator.getFrameworks(span) - - then: - frameworks == expected - - where: - frameworkTag | frameworkVersionTag | expected - "name" | "version" | [new TestFramework("name", "version")] - "name" | null | [new TestFramework("name", null)] - null | "version" | [] - ["nameA", "nameB"] | ["versionA", "versionB"] | [new TestFramework("nameA", "versionA"), new TestFramework("nameB", "versionB")] - ["nameA", "nameB"] | null | [new TestFramework("nameA", null), new TestFramework("nameB", null)] - ["nameA", "nameB"] | ["versionA", null] | [new TestFramework("nameA", "versionA"), new TestFramework("nameB", null)] - } - - def "test status propagation: #childStatus to #parentStatus"() { - given: - def parentSpan = Mock(DDSpan) - parentSpan.getTag(Tags.TEST_STATUS) >> parentStatus - - def childSpan = Mock(DDSpan) - childSpan.getTag(Tags.TEST_STATUS) >> childStatus - - def propagator = new SpanTagsPropagator(parentSpan) - - when: - propagator.propagateStatus(childSpan) - - then: - if (expectedStatus != null) { - 1 * parentSpan.setTag(Tags.TEST_STATUS, expectedStatus) - } else { - 0 * parentSpan.setTag(Tags.TEST_STATUS, _) - } - - where: - childStatus | parentStatus | expectedStatus - TestStatus.pass | null | TestStatus.pass - TestStatus.pass | TestStatus.skip | TestStatus.pass - TestStatus.pass | TestStatus.pass | null // no change - TestStatus.pass | TestStatus.fail | null // no change - TestStatus.fail | null | TestStatus.fail - TestStatus.fail | TestStatus.pass | TestStatus.fail - TestStatus.fail | TestStatus.skip | TestStatus.fail - TestStatus.fail | TestStatus.fail | TestStatus.fail - TestStatus.skip | null | TestStatus.skip - TestStatus.skip | TestStatus.pass | null // no change - TestStatus.skip | TestStatus.fail | null // no change - TestStatus.skip | TestStatus.skip | null // no change - null | TestStatus.pass | null // no change - } - - def "test framework merging: #childFrameworks and #parentFrameworks"() { - given: - def parentSpan = Mock(DDSpan) - parentSpan.getTag(Tags.TEST_FRAMEWORK) >> parentFrameworks.collect(it -> it.getName()) - parentSpan.getTag(Tags.TEST_FRAMEWORK_VERSION) >> parentFrameworks.collect(it -> it.getVersion()) - - def propagator = new SpanTagsPropagator(parentSpan) - - def expectedNames = expectedFrameworks.collect(it -> it.getName()) - def expectedVersions = expectedFrameworks.collect(it -> it.getVersion()) - - when: - propagator.mergeTestFrameworks(childFrameworks) - - then: - 1 * parentSpan.setAllTags([ - (Tags.TEST_FRAMEWORK) : expectedNames, - (Tags.TEST_FRAMEWORK_VERSION): expectedVersions - ]) - - where: - childFrameworks | parentFrameworks | expectedFrameworks - [] | [new TestFramework("JUnit", "5.8.0"), new TestFramework("TestNG", "7.4.0")] | [new TestFramework("JUnit", "5.8.0"), new TestFramework("TestNG", "7.4.0")] - [new TestFramework("JUnit", "5.8.0"), new TestFramework("TestNG", "7.4.0")] | [] | [new TestFramework("JUnit", "5.8.0"), new TestFramework("TestNG", "7.4.0")] - [new TestFramework("JUnit", "5.8.0"), new TestFramework("TestNG", "7.4.0")] | [new TestFramework("Spock", "2.3")] | [new TestFramework("JUnit", "5.8.0"), new TestFramework("Spock", "2.3"), new TestFramework("TestNG", "7.4.0")] - } - - def "test tag propagation: #childValue and #parentValue with spec #tagSpec"() { - given: - def parentSpan = Mock(DDSpan) - parentSpan.getTag("tag") >> parentValue - - def childSpan = Mock(DDSpan) - childSpan.getTag("tag") >> childValue - - def propagator = new SpanTagsPropagator(parentSpan) - - when: - propagator.propagateTags(childSpan, tagSpec) - - then: - if (expectedChange) { - 1 * parentSpan.setTag("tag", expectedValue) - } else { - 0 * parentSpan.setTag("tag", _) - } - - where: - tagSpec | childValue | parentValue | expectedChange | expectedValue - TagMergeSpec.of("tag") | "a" | "b" | true | "a" - TagMergeSpec.of("tag") | null | "b" | false | "b" - TagMergeSpec.of("tag") | null | null | false | null - TagMergeSpec.of("tag", Boolean::logicalOr) | true | false | true | true - TagMergeSpec.of("tag", Boolean::logicalOr) | false | false | true | false - } - - // Mocks AgentSpan (interface) rather than DDSpan because propagateCustomTags writes through - // the final DDSpan#setTag(String, String) overload, which Spock cannot intercept on a class mock. - def "test custom tag propagation from span: child=#childValue, parent=#parentValue, key=#key, allowlist=#allowlist"() { - given: - def parentSpan = Mock(AgentSpan) - parentSpan.getTag(key) >> parentValue - - def childSpan = Mock(AgentSpan) - childSpan.getTag(key) >> childValue - - def propagator = new SpanTagsPropagator(parentSpan, allowlist) - - when: - propagator.propagateCustomTags(childSpan) - - then: - if (expectedValue != null) { - 1 * parentSpan.setTag(key, expectedValue) - } else { - 0 * parentSpan.setTag(key, _) - } - - where: - allowlist | key | childValue | parentValue | expectedValue - ["bazel.shard_index"] | "bazel.shard_index" | "0" | null | "0" - ["bazel.shard_index"] | "bazel.shard_index" | "1" | "0" | "1" // child overrides parent - ["bazel.shard_index"] | "bazel.shard_index" | null | "0" | null // missing on child, no-op - ["bazel.shard_index"] | "bazel.total_shards" | "2" | null | null // not in allowlist - [] | "bazel.shard_index" | "0" | null | null // empty allowlist - null | "bazel.shard_index" | "0" | null | null // null allowlist - ["bazel.shard_index"] | "bazel.shard_index" | 0L | null | "0" // non-string child stringified - ["bazel.shard_index"] | "bazel.shard_index" | true | null | "true" // boolean stringified - } - - def "test custom tag propagation from map: allowlist=#allowlist, tags=#tags"() { - given: - def parentSpan = Mock(AgentSpan) - def propagator = new SpanTagsPropagator(parentSpan, allowlist) - - when: - propagator.propagateCustomTags(tags) - - then: - expectedSets * parentSpan.setTag(_, _) - - where: - allowlist | tags | expectedSets - ["bazel.shard_index", "bazel.total_shards"] | ["bazel.shard_index": "0", "bazel.total_shards": "2"] | 2 - ["bazel.shard_index"] | ["bazel.shard_index": "0"] | 1 - ["bazel.shard_index"] | ["bazel.shard_index": "0", "bazel.total_shards": "2"] | 1 // only allowlisted keys are copied - ["bazel.shard_index"] | [:] | 0 // empty tags - [] | ["bazel.shard_index": "0"] | 0 // empty allowlist - null | ["bazel.shard_index": "0"] | 0 // null allowlist - } - - def "test synchronized propagation"() { - given: - def parentSpan = Mock(DDSpan) - def propagator = new SpanTagsPropagator(parentSpan) - def numThreads = 9 - def latch = new CountDownLatch(numThreads) - def executor = Executors.newFixedThreadPool(numThreads) - def exceptions = Collections.synchronizedList([]) - - when: - numThreads.times { i -> - executor.submit { - try { - switch (i % 3) { - case 0: - def childSpan = Mock(DDSpan) - childSpan.getTag(Tags.TEST_STATUS) >> TestStatus.fail - propagator.propagateStatus(childSpan) - break - case 1: - def frameworks = [new TestFramework("JUnit${i}", "5.${i}")] - propagator.mergeTestFrameworks(frameworks) - break - case 2: - def childSpan = Mock(DDSpan) - childSpan.getTag("custom.tag.${i}") >> "value${i}" - propagator.propagateTags(childSpan, TagMergeSpec.of("custom.tag.${i}")) - break - } - } catch (Exception e) { - exceptions.add(e) - } finally { - latch.countDown() - } - } - } - - latch.await(5, TimeUnit.SECONDS) - executor.shutdown() - - then: - exceptions.isEmpty() - 3 * parentSpan.setTag(Tags.TEST_STATUS, TestStatus.fail) - 3 * parentSpan.setAllTags(_) - 3 * parentSpan.setTag({ it.startsWith("custom.tag.") }, { it.startsWith("value") }) - } -} diff --git a/dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.java b/dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.java new file mode 100644 index 00000000000..ebb7fc3d948 --- /dev/null +++ b/dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/domain/SpanTagsPropagatorTest.java @@ -0,0 +1,331 @@ +package datadog.trace.civisibility.domain; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.trace.api.civisibility.execution.TestStatus; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.civisibility.domain.SpanTagsPropagator.TagMergeSpec; +import datadog.trace.civisibility.ipc.TestFramework; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +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.MethodSource; + +class SpanTagsPropagatorTest { + + @ParameterizedTest + @MethodSource("frameworkTags") + void testGetFrameworks( + Object frameworkTag, + Object frameworkVersionTag, + Collection expectedFrameworks) { + AgentSpan span = mock(AgentSpan.class); + when(span.getTag(Tags.TEST_FRAMEWORK)).thenReturn(frameworkTag); + when(span.getTag(Tags.TEST_FRAMEWORK_VERSION)).thenReturn(frameworkVersionTag); + + Collection frameworks = SpanTagsPropagator.getFrameworks(span); + + assertEquals(expectedFrameworks, frameworks); + } + + private static Stream frameworkTags() { + return Stream.of( + Arguments.of("name", "version", singletonList(new TestFramework("name", "version"))), + Arguments.of("name", null, singletonList(new TestFramework("name", null))), + Arguments.of(null, "version", emptyList()), + Arguments.of( + asList("nameA", "nameB"), + asList("versionA", "versionB"), + asList(new TestFramework("nameA", "versionA"), new TestFramework("nameB", "versionB"))), + Arguments.of( + asList("nameA", "nameB"), + null, + asList(new TestFramework("nameA", null), new TestFramework("nameB", null))), + Arguments.of( + asList("nameA", "nameB"), + asList("versionA", null), + asList(new TestFramework("nameA", "versionA"), new TestFramework("nameB", null)))); + } + + @ParameterizedTest + @MethodSource("statusPropagations") + void testStatusPropagation( + TestStatus childStatus, TestStatus parentStatus, TestStatus expectedStatus) { + AgentSpan parentSpan = mock(AgentSpan.class); + when(parentSpan.getTag(Tags.TEST_STATUS)).thenReturn(parentStatus); + + AgentSpan childSpan = mock(AgentSpan.class); + when(childSpan.getTag(Tags.TEST_STATUS)).thenReturn(childStatus); + + SpanTagsPropagator propagator = new SpanTagsPropagator(parentSpan); + + propagator.propagateStatus(childSpan); + + if (expectedStatus != null) { + verify(parentSpan).setTag(Tags.TEST_STATUS, expectedStatus); + } else { + verify(parentSpan, never()).setTag(eq(Tags.TEST_STATUS), isA(Object.class)); + } + } + + private static Stream statusPropagations() { + return Stream.of( + Arguments.of(TestStatus.pass, null, TestStatus.pass), + Arguments.of(TestStatus.pass, TestStatus.skip, TestStatus.pass), + Arguments.of(TestStatus.pass, TestStatus.pass, null), + Arguments.of(TestStatus.pass, TestStatus.fail, null), + Arguments.of(TestStatus.fail, null, TestStatus.fail), + Arguments.of(TestStatus.fail, TestStatus.pass, TestStatus.fail), + Arguments.of(TestStatus.fail, TestStatus.skip, TestStatus.fail), + Arguments.of(TestStatus.fail, TestStatus.fail, TestStatus.fail), + Arguments.of(TestStatus.skip, null, TestStatus.skip), + Arguments.of(TestStatus.skip, TestStatus.pass, null), + Arguments.of(TestStatus.skip, TestStatus.fail, null), + Arguments.of(TestStatus.skip, TestStatus.skip, null), + Arguments.of(null, TestStatus.pass, null)); + } + + @ParameterizedTest + @MethodSource("frameworkMerges") + void testFrameworkMerging( + Collection childFrameworks, + Collection parentFrameworks, + Collection expectedFrameworks) { + AgentSpan parentSpan = mock(AgentSpan.class); + when(parentSpan.getTag(Tags.TEST_FRAMEWORK)).thenReturn(names(parentFrameworks)); + when(parentSpan.getTag(Tags.TEST_FRAMEWORK_VERSION)).thenReturn(versions(parentFrameworks)); + + SpanTagsPropagator propagator = new SpanTagsPropagator(parentSpan); + Map> expectedTags = new HashMap<>(); + expectedTags.put(Tags.TEST_FRAMEWORK, names(expectedFrameworks)); + expectedTags.put(Tags.TEST_FRAMEWORK_VERSION, versions(expectedFrameworks)); + + propagator.mergeTestFrameworks(childFrameworks); + + verify(parentSpan).setAllTags(expectedTags); + } + + private static Stream frameworkMerges() { + return Stream.of( + Arguments.of( + emptyList(), + asList(new TestFramework("JUnit", "5.8.0"), new TestFramework("TestNG", "7.4.0")), + asList(new TestFramework("JUnit", "5.8.0"), new TestFramework("TestNG", "7.4.0"))), + Arguments.of( + asList(new TestFramework("JUnit", "5.8.0"), new TestFramework("TestNG", "7.4.0")), + emptyList(), + asList(new TestFramework("JUnit", "5.8.0"), new TestFramework("TestNG", "7.4.0"))), + Arguments.of( + asList(new TestFramework("JUnit", "5.8.0"), new TestFramework("TestNG", "7.4.0")), + singletonList(new TestFramework("Spock", "2.3")), + asList( + new TestFramework("JUnit", "5.8.0"), + new TestFramework("Spock", "2.3"), + new TestFramework("TestNG", "7.4.0")))); + } + + @ParameterizedTest + @MethodSource("tagPropagations") + void testTagPropagation( + TagMergeSpec tagSpec, + T childValue, + T parentValue, + boolean expectedChange, + T expectedValue) { + AgentSpan parentSpan = mock(AgentSpan.class); + when(parentSpan.getTag("tag")).thenReturn(parentValue); + + AgentSpan childSpan = mock(AgentSpan.class); + when(childSpan.getTag("tag")).thenReturn(childValue); + + SpanTagsPropagator propagator = new SpanTagsPropagator(parentSpan); + + propagator.propagateTags(childSpan, tagSpec); + + if (expectedChange) { + verify(parentSpan).setTag("tag", expectedValue); + } else { + verify(parentSpan, never()).setTag(eq("tag"), isA(Object.class)); + } + } + + private static Stream tagPropagations() { + return Stream.of( + Arguments.of(TagMergeSpec.of("tag"), "a", "b", true, "a"), + Arguments.of(TagMergeSpec.of("tag"), null, "b", false, "b"), + Arguments.of(TagMergeSpec.of("tag"), null, null, false, null), + Arguments.of(TagMergeSpec.of("tag", Boolean::logicalOr), true, false, true, true), + Arguments.of(TagMergeSpec.of("tag", Boolean::logicalOr), false, false, true, false)); + } + + @ParameterizedTest + @MethodSource("customTagSpanPropagations") + void testCustomTagPropagationFromSpan( + Collection allowlist, + String key, + Object childValue, + Object parentValue, + Object expectedValue) { + AgentSpan parentSpan = mock(AgentSpan.class); + when(parentSpan.getTag(key)).thenReturn(parentValue); + + AgentSpan childSpan = mock(AgentSpan.class); + when(childSpan.getTag(key)).thenReturn(childValue); + + SpanTagsPropagator propagator = new SpanTagsPropagator(parentSpan, allowlist); + + propagator.propagateCustomTags(childSpan); + + if (expectedValue != null) { + verify(parentSpan).setTag(key, expectedValue); + } else { + verify(parentSpan, never()).setTag(eq(key), isA(Object.class)); + } + } + + private static Stream customTagSpanPropagations() { + return Stream.of( + Arguments.of(singletonList("example.number"), "example.number", 1L, null, 1L), + Arguments.of(singletonList("example.number"), "example.number", 2L, 1L, 2L), + Arguments.of(singletonList("example.number"), "example.number", null, 1L, null), + Arguments.of(singletonList("example.number"), "example.count", 4, null, null), + Arguments.of(emptyList(), "example.number", 1L, null, null), + Arguments.of(null, "example.number", 1L, null, null), + Arguments.of(singletonList("example.flag"), "example.flag", true, null, true), + Arguments.of(singletonList("example.ratio"), "example.ratio", 0.5d, null, 0.5d), + Arguments.of(singletonList("example.label"), "example.label", "red", null, "red")); + } + + @ParameterizedTest + @MethodSource("customTagMapPropagations") + void testCustomTagPropagationFromMap( + Collection allowlist, Map tags, int expectedSets) { + AgentSpan parentSpan = mock(AgentSpan.class); + SpanTagsPropagator propagator = new SpanTagsPropagator(parentSpan, allowlist); + + propagator.propagateCustomTags(tags); + + verify(parentSpan, times(expectedSets)).setTag(any(String.class), any(Object.class)); + } + + private static Stream customTagMapPropagations() { + return Stream.of( + Arguments.of( + asList("example.number", "example.count"), + mapOf("example.number", 1L, "example.count", 4), + 2), + Arguments.of(singletonList("example.flag"), mapOf("example.flag", true), 1), + Arguments.of( + singletonList("example.number"), mapOf("example.number", 1L, "example.count", 4), 1), + Arguments.of(singletonList("example.ratio"), Collections.emptyMap(), 0), + Arguments.of(emptyList(), mapOf("example.ratio", 0.5d), 0), + Arguments.of(null, mapOf("example.ratio", 0.5d), 0)); + } + + @Test + void testSynchronizedPropagation() throws InterruptedException { + AgentSpan parentSpan = mock(AgentSpan.class); + SpanTagsPropagator propagator = new SpanTagsPropagator(parentSpan); + int numThreads = 9; + CountDownLatch latch = new CountDownLatch(numThreads); + ExecutorService executor = Executors.newFixedThreadPool(numThreads); + List exceptions = Collections.synchronizedList(new java.util.ArrayList<>()); + + for (int i = 0; i < numThreads; i++) { + final int index = i; + executor.submit( + () -> { + try { + switch (index % 3) { + case 0: + AgentSpan childSpan = mock(AgentSpan.class); + when(childSpan.getTag(Tags.TEST_STATUS)).thenReturn(TestStatus.fail); + propagator.propagateStatus(childSpan); + break; + case 1: + Collection frameworks = + singletonList(new TestFramework("JUnit" + index, "5." + index)); + propagator.mergeTestFrameworks(frameworks); + break; + case 2: + AgentSpan customTagChildSpan = mock(AgentSpan.class); + String tagKey = "custom.tag." + index; + when(customTagChildSpan.getTag(tagKey)).thenReturn("value" + index); + propagator.propagateTags(customTagChildSpan, TagMergeSpec.of(tagKey)); + break; + default: + throw new IllegalStateException("Unexpected remainder"); + } + } catch (Exception e) { + exceptions.add(e); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + executor.shutdown(); + + assertTrue(exceptions.isEmpty()); + verify(parentSpan, times(3)).setTag(Tags.TEST_STATUS, TestStatus.fail); + verify(parentSpan, times(3)).setAllTags(any()); + verify(parentSpan, times(3)) + .setTag( + argThat((String key) -> key.startsWith("custom.tag.")), + argThat((Object value) -> value.toString().startsWith("value"))); + } + + private static Collection names(Collection frameworks) { + List names = new java.util.ArrayList<>(); + for (TestFramework framework : frameworks) { + names.add(framework.getName()); + } + return names; + } + + private static Collection versions(Collection frameworks) { + List versions = new java.util.ArrayList<>(); + for (TestFramework framework : frameworks) { + versions.add(framework.getVersion()); + } + return versions; + } + + private static Map mapOf(String key, Object value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } + + private static Map mapOf( + String firstKey, Object firstValue, String secondKey, Object secondValue) { + Map map = mapOf(firstKey, firstValue); + map.put(secondKey, secondValue); + return map; + } +} From 3d5e2798e2ce775731b37df5de860a27e722fa42 Mon Sep 17 00:00:00 2001 From: Daniel Mohedano Date: Thu, 14 May 2026 16:04:49 +0200 Subject: [PATCH 3/3] feat: keep typing on tag propagation --- .../domain/SpanTagsPropagator.java | 6 +- .../domain/buildsystem/ProxyTestModule.java | 4 +- .../ipc/ModuleExecutionResult.java | 10 +-- .../ipc/serialization/Serializer.java | 79 +++++++++++++++++++ .../headless/HeadlessTestSessionTest.groovy | 5 +- .../domain/manualapi/ManualApiTest.groovy | 6 +- .../ipc/ModuleExecutionResultTest.groovy | 1 + 7 files changed, 97 insertions(+), 14 deletions(-) diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/SpanTagsPropagator.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/SpanTagsPropagator.java index 0615b913975..dbf5e2a9199 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/SpanTagsPropagator.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/SpanTagsPropagator.java @@ -65,19 +65,19 @@ public void propagateCustomTags(AgentSpan childSpan) { for (String key : propagatedTagKeys) { Object value = childSpan.getTag(key); if (value != null) { - parentSpan.setTag(key, String.valueOf(value)); + parentSpan.setTag(key, value); } } } } - public void propagateCustomTags(Map tags) { + public void propagateCustomTags(Map tags) { if (propagatedTagKeys.isEmpty() || tags == null || tags.isEmpty()) { return; } synchronized (tagPropagationLock) { for (String key : propagatedTagKeys) { - String value = tags.get(key); + Object value = tags.get(key); if (value != null) { parentSpan.setTag(key, value); } diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/ProxyTestModule.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/ProxyTestModule.java index ef0272315c9..af942fe4cb0 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/ProxyTestModule.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/buildsystem/ProxyTestModule.java @@ -63,7 +63,7 @@ public class ProxyTestModule implements TestFrameworkModule { private final LinesResolver linesResolver; private final CoverageStore.Factory coverageStoreFactory; private final Collection testFrameworks = ConcurrentHashMap.newKeySet(); - private final Map propagatedTags = new ConcurrentHashMap<>(); + private final Map propagatedTags = new ConcurrentHashMap<>(); private final Set propagatedTagKeys; private final Collection capabilities; @@ -237,7 +237,7 @@ private void propagateData(AgentSpan childSpan) { for (String key : propagatedTagKeys) { Object value = childSpan.getTag(key); if (value != null) { - propagatedTags.put(key, String.valueOf(value)); + propagatedTags.put(key, value); } } } diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/ModuleExecutionResult.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/ModuleExecutionResult.java index da9df04f740..bb4388241d7 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/ModuleExecutionResult.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/ModuleExecutionResult.java @@ -25,7 +25,7 @@ public class ModuleExecutionResult extends ModuleSignal { private final boolean hasFailedTestReplayTests; private final long testsSkippedTotal; private final Collection testFrameworks; - private final Map propagatedTags; + private final Map propagatedTags; public ModuleExecutionResult( DDTraceId sessionId, @@ -38,7 +38,7 @@ public ModuleExecutionResult( boolean hasFailedTestReplayTests, long testsSkippedTotal, Collection testFrameworks, - Map propagatedTags) { + Map propagatedTags) { super(sessionId, moduleId); this.coverageEnabled = coverageEnabled; this.testSkippingEnabled = testSkippingEnabled; @@ -83,7 +83,7 @@ public Collection getTestFrameworks() { return testFrameworks; } - public Map getPropagatedTags() { + public Map getPropagatedTags() { return propagatedTags; } @@ -172,7 +172,7 @@ public ByteBuffer serialize() { s.write(testsSkippedTotal); s.write(testFrameworks, TestFramework::serialize); - s.write(propagatedTags); + s.writeObjectMap(propagatedTags); return s.flush(); } @@ -192,7 +192,7 @@ public static ModuleExecutionResult deserialize(ByteBuffer buffer) { long testsSkippedTotal = Serializer.readLong(buffer); Collection testFrameworks = Serializer.readList(buffer, TestFramework::deserialize); - Map propagatedTags = Serializer.readStringMap(buffer); + Map propagatedTags = Serializer.readObjectMap(buffer); return new ModuleExecutionResult( sessionId, diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/serialization/Serializer.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/serialization/Serializer.java index 60feee6fd9d..ade8abcc55c 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/serialization/Serializer.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ipc/serialization/Serializer.java @@ -16,6 +16,13 @@ import java.util.function.Supplier; public class Serializer { + private static final byte NULL = 0; + private static final byte STRING = 1; + private static final byte BOOLEAN = 2; + private static final byte INTEGER = 3; + private static final byte LONG = 4; + private static final byte FLOAT = 5; + private static final byte DOUBLE = 6; private final ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -45,6 +52,14 @@ public void write(long l) { baos.write((int) l); } + public void write(float f) { + write(Float.floatToIntBits(f)); + } + + public void write(double d) { + write(Double.doubleToLongBits(d)); + } + public void write(String s) { if (s == null) { write(-1); @@ -81,6 +96,10 @@ public void write(Map m) { write(m, Serializer::write, Serializer::write); } + public void writeObjectMap(Map m) { + write(m, Serializer::write, Serializer::writeObject); + } + public void write( Map m, BiConsumer keySerializer, @@ -104,6 +123,32 @@ public void write(BitSet bitSet) { } } + private void writeObject(Object value) { + if (value == null) { + write(NULL); + } else if (value instanceof String) { + write(STRING); + write((String) value); + } else if (value instanceof Boolean) { + write(BOOLEAN); + write((boolean) value); + } else if (value instanceof Integer) { + write(INTEGER); + write((int) value); + } else if (value instanceof Long) { + write(LONG); + write((long) value); + } else if (value instanceof Float) { + write(FLOAT); + write((float) value); + } else if (value instanceof Double) { + write(DOUBLE); + write((double) value); + } else { + throw new IllegalArgumentException("Unsupported value type: " + value.getClass()); + } + } + public int length() { return baos.size(); } @@ -133,6 +178,14 @@ public static long readLong(ByteBuffer byteBuffer) { return byteBuffer.getLong(); } + public static float readFloat(ByteBuffer byteBuffer) { + return Float.intBitsToFloat(readInt(byteBuffer)); + } + + public static double readDouble(ByteBuffer byteBuffer) { + return Double.longBitsToDouble(readLong(byteBuffer)); + } + public static String readString(ByteBuffer byteBuffer) { byte[] b = readByteArray(byteBuffer); return b != null ? new String(b, StandardCharsets.UTF_8) : null; @@ -175,6 +228,10 @@ public static Map readStringMap(ByteBuffer byteBuffer) { return readMap(byteBuffer, Serializer::readString, Serializer::readString); } + public static Map readObjectMap(ByteBuffer byteBuffer) { + return readMap(byteBuffer, Serializer::readString, Serializer::readObject); + } + public static Map readMap( ByteBuffer byteBuffer, Function keyDeserializer, @@ -212,6 +269,28 @@ private static Map fillMap( return m; } + private static Object readObject(ByteBuffer byteBuffer) { + byte type = readByte(byteBuffer); + switch (type) { + case NULL: + return null; + case STRING: + return readString(byteBuffer); + case BOOLEAN: + return readBoolean(byteBuffer); + case INTEGER: + return readInt(byteBuffer); + case LONG: + return readLong(byteBuffer); + case FLOAT: + return readFloat(byteBuffer); + case DOUBLE: + return readDouble(byteBuffer); + default: + throw new IllegalArgumentException("Unsupported value type: " + type); + } + } + public static BitSet readBitSet(ByteBuffer byteBuffer) { byte[] bytes = readByteArray(byteBuffer); return bytes != null ? BitSet.valueOf(bytes) : null; diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/headless/HeadlessTestSessionTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/headless/HeadlessTestSessionTest.groovy index 399c3477500..4e6a1ff5f63 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/headless/HeadlessTestSessionTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/headless/HeadlessTestSessionTest.groovy @@ -25,6 +25,7 @@ class HeadlessTestSessionTest extends SpanWriterTest { when: module.setTag("custom.propagated_tag", "value") + module.setTag("custom.another_tag", 0.5d) module.end(null) session.end(null) @@ -36,12 +37,14 @@ class HeadlessTestSessionTest extends SpanWriterTest { tags(false) { "$Tags.TEST_TEST_MANAGEMENT_ENABLED" true "custom.propagated_tag" "value" + "custom.another_tag" 0.5d } } span(1) { spanType DDSpanTypes.TEST_MODULE_END tags(false) { "custom.propagated_tag" "value" + "custom.another_tag" 0.5d } } } @@ -53,7 +56,7 @@ class HeadlessTestSessionTest extends SpanWriterTest { executionSettings.getTestManagementSettings() >> new TestManagementSettings(true, 10) def config = Stub(Config) - config.getCiVisibilityPropagatedTagKeys() >> ["custom.propagated_tag"] + config.getCiVisibilityPropagatedTagKeys() >> ["custom.propagated_tag", "custom.another_tag"] def executionStrategy = new ExecutionStrategy(config, executionSettings, Stub(SourcePathResolver), Stub(LinesResolver)) diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/manualapi/ManualApiTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/manualapi/ManualApiTest.groovy index ef42e2f573a..c59f1d3dc95 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/manualapi/ManualApiTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/domain/manualapi/ManualApiTest.groovy @@ -50,13 +50,13 @@ class ManualApiTest extends SpanWriterTest { suiteSpan.tags[Tags.TEST_FRAMEWORK] == component testSpan.tags[Tags.TEST_FRAMEWORK] == component sessionSpan.tags["custom.tag"] == "something" - sessionSpan.tags["custom.another_tag"] == "2" + sessionSpan.tags["custom.another_tag"] == 2 sessionSpan.tags["custom.third_tag"] == null moduleSpan.tags["custom.tag"] == "something" - moduleSpan.tags["custom.another_tag"] == "2" + moduleSpan.tags["custom.another_tag"] == 2 moduleSpan.tags["custom.third_tag"] == null suiteSpan.tags["custom.tag"] == "something" - suiteSpan.tags["custom.another_tag"] == "2" + suiteSpan.tags["custom.another_tag"] == 2 suiteSpan.tags["custom.third_tag"] == null } diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/ModuleExecutionResultTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/ModuleExecutionResultTest.groovy index f8762b9cb52..5fd3ac51c4c 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/ModuleExecutionResultTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/ipc/ModuleExecutionResultTest.groovy @@ -19,6 +19,7 @@ class ModuleExecutionResultTest extends Specification { new ModuleExecutionResult(DDTraceId.from(12345), 67890, false, false, false, false, false, false, 0, Collections.emptyList(), Collections.emptyMap()), new ModuleExecutionResult(DDTraceId.from(12345), 67890, true, false, true, true, true, true, 1, Collections.singletonList(new TestFramework("junit", "4.13.2")), Collections.emptyMap()), new ModuleExecutionResult(DDTraceId.from(12345), 67890, false, true, true, false, false, true, 2, Arrays.asList(new TestFramework("junit", "4.13.2"), new TestFramework("junit", "5.9.2")), ["bazel.shard_index": "0", "bazel.total_shards": "2"]), + new ModuleExecutionResult(DDTraceId.from(12345), 67890, false, true, true, false, false, true, 2, Arrays.asList(new TestFramework("junit", "4.13.2"), new TestFramework("junit", "5.9.2")), ["bazel.shard_index": 0, "bazel.total_shards": 2L, "custom.success": true, "custom.ratio": 0.5d]), new ModuleExecutionResult(DD128bTraceId.from(12345, 67890), 67890, false, false, false, true, true, false, 3, Arrays.asList(new TestFramework("junit", null), new TestFramework("junit", "5.9.2")), Collections.emptyMap()), new ModuleExecutionResult(DD128bTraceId.from(12345, 67890), 67890, true, true, true, true, true, true, Integer.MAX_VALUE, Arrays.asList(new TestFramework("junit", "4.13.2"), new TestFramework(null, "5.9.2")), ["custom.tier": "gold", "custom.region": "us-east-1"]) ]