diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityRepoServices.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityRepoServices.java index bdbf2148c10..8cb9443968d 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityRepoServices.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityRepoServices.java @@ -2,6 +2,7 @@ import datadog.communication.BackendApi; import datadog.trace.api.Config; +import datadog.trace.api.civisibility.config.BazelMode; import datadog.trace.api.civisibility.telemetry.CiVisibilityMetricCollector; import datadog.trace.api.civisibility.telemetry.tag.Provider; import datadog.trace.api.git.CommitInfo; @@ -19,6 +20,7 @@ import datadog.trace.civisibility.config.ExecutionSettings; import datadog.trace.civisibility.config.ExecutionSettingsFactory; import datadog.trace.civisibility.config.ExecutionSettingsFactoryImpl; +import datadog.trace.civisibility.config.FileBasedConfigurationApi; import datadog.trace.civisibility.config.JvmInfo; import datadog.trace.civisibility.config.MultiModuleExecutionSettingsFactory; import datadog.trace.civisibility.git.tree.GitClient; @@ -81,15 +83,22 @@ public class CiVisibilityRepoServices { ciTags = new CITagsProvider().getCiTags(ciInfo, pullRequestInfo); - gitDataUploader = - buildGitDataUploader( - services.config, - services.metricCollector, - services.gitInfoProvider, - gitClient, - gitRepoUnshallow, - services.backendApi, - repoRoot); + if (BazelMode.get().isEnabled()) { + // bazel rule takes care of the git data upload + LOGGER.info("[bazel mode] Skipping git data upload"); + gitDataUploader = () -> CompletableFuture.completedFuture(null); + } else { + gitDataUploader = + buildGitDataUploader( + services.config, + services.metricCollector, + services.gitInfoProvider, + gitClient, + gitRepoUnshallow, + services.backendApi, + repoRoot); + } + repoIndexProvider = services.repoIndexProviderFactory.create(repoRoot); codeowners = buildCodeowners(repoRoot); sourcePathResolver = buildSourcePathResolver(repoRoot, repoIndexProvider); @@ -242,7 +251,17 @@ private static ExecutionSettingsFactory buildExecutionSettingsFactory( PullRequestInfo pullRequestInfo, @Nullable String repoRoot) { ConfigurationApi configurationApi; - if (backendApi == null) { + BazelMode bazelMode = BazelMode.get(); + if (bazelMode.isManifestModeEnabled()) { + LOGGER.info("[bazel mode] Manifest mode detected. Using file-based configuration API"); + configurationApi = + new FileBasedConfigurationApi( + bazelMode.getSettingsPath(), + null, + bazelMode.getFlakyTestsPath(), + bazelMode.getKnownTestsPath(), + bazelMode.getTestManagementPath()); + } else if (backendApi == null) { LOGGER.warn( "Remote config and skippable tests requests will be skipped since backend API client could not be created"); configurationApi = ConfigurationApi.NO_OP; diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityServices.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityServices.java index c2c4d0cccbe..12dfa0709d7 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityServices.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityServices.java @@ -5,6 +5,7 @@ import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.communication.util.IOUtils; import datadog.trace.api.Config; +import datadog.trace.api.civisibility.config.BazelMode; import datadog.trace.api.civisibility.telemetry.CiVisibilityCountMetric; import datadog.trace.api.civisibility.telemetry.CiVisibilityMetricCollector; import datadog.trace.api.civisibility.telemetry.tag.Command; @@ -83,7 +84,14 @@ public class CiVisibilityServices { this.backendApi = new BackendApiFactory(config, sco).createBackendApi(Intake.API); this.ciIntake = new BackendApiFactory(config, sco).createBackendApi(Intake.CI_INTAKE); this.jvmInfoFactory = new CachingJvmInfoFactory(config, new JvmInfoFactoryImpl()); - this.gitClientFactory = buildGitClientFactory(config, metricCollector); + + if (BazelMode.get().isPayloadFilesEnabled()) { + // git commands should not be executed in payload files mode + logger.info("[bazel mode] Payload-in-files mode detected. Disabling git commands"); + this.gitClientFactory = r -> NoOpGitClient.INSTANCE; + } else { + this.gitClientFactory = buildGitClientFactory(config, metricCollector); + } this.environment = buildCiEnvironment(); this.ciProviderInfoFactory = new CIProviderInfoFactory(config, environment); diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/ConfigurationApiImpl.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/ConfigurationApiImpl.java index 79bc4899b30..9dcc6d65f10 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/ConfigurationApiImpl.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/ConfigurationApiImpl.java @@ -1,10 +1,8 @@ package datadog.trace.civisibility.config; -import com.squareup.moshi.FromJson; import com.squareup.moshi.Json; import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; -import com.squareup.moshi.ToJson; import com.squareup.moshi.Types; import datadog.communication.BackendApi; import datadog.communication.http.OkHttpUtils; @@ -27,10 +25,8 @@ import datadog.trace.api.civisibility.telemetry.tag.TestManagementEnabled; import datadog.trace.civisibility.communication.TelemetryListener; import datadog.trace.util.RandomUtils; -import java.io.File; import java.io.IOException; import java.lang.reflect.ParameterizedType; -import java.util.Base64; import java.util.BitSet; import java.util.Collection; import java.util.Collections; @@ -87,7 +83,7 @@ public ConfigurationApiImpl(BackendApi backendApi, CiVisibilityMetricCollector m .add(ConfigurationsJsonAdapter.INSTANCE) .add(CiVisibilitySettings.JsonAdapter.INSTANCE) .add(EarlyFlakeDetectionSettings.JsonAdapter.INSTANCE) - .add(MetaDtoJsonAdapter.INSTANCE) + .add(MetaDto.JsonAdapter.INSTANCE) .build(); ParameterizedType requestType = @@ -208,7 +204,7 @@ public SkippableTests getSkippableTests(TracerEnvironment tracerEnvironment) thr metricCollector.add( CiVisibilityCountMetric.ITR_SKIPPABLE_TESTS_RESPONSE_TESTS, response.data.size()); - String correlationId = response.meta != null ? response.meta.correlation_id : null; + String correlationId = response.meta != null ? response.meta.correlationId : null; Map coveredLinesByRelativeSourcePath = response.meta != null && response.meta.coverage != null ? response.meta.coverage @@ -499,6 +495,7 @@ private MultiEnvelopeDto(Collection> data, MetaDto meta) { } private static final class DataDto { + // TODO: extract all DTO logic to common utilities private final String id; private final String type; private final T attributes; @@ -514,52 +511,6 @@ public T getAttributes() { } } - private static final class MetaDto { - private final String correlation_id; - private final Map coverage; - - private MetaDto(String correlation_id, Map coverage) { - this.correlation_id = correlation_id; - this.coverage = coverage; - } - } - - private static final class MetaDtoJsonAdapter { - - private static final MetaDtoJsonAdapter INSTANCE = new MetaDtoJsonAdapter(); - - @FromJson - public MetaDto fromJson(Map json) { - if (json == null) { - return null; - } - - Map coverage; - Map encodedCoverage = (Map) json.get("coverage"); - if (encodedCoverage != null) { - coverage = new HashMap<>(); - for (Map.Entry e : encodedCoverage.entrySet()) { - String relativeSourceFilePath = e.getKey(); - String normalizedSourceFilePath = - relativeSourceFilePath.startsWith(File.separator) - ? relativeSourceFilePath.substring(1) - : relativeSourceFilePath; - byte[] decodedLines = Base64.getDecoder().decode(e.getValue()); - coverage.put(normalizedSourceFilePath, BitSet.valueOf(decodedLines)); - } - } else { - coverage = null; - } - - return new MetaDto((String) json.get("correlation_id"), coverage); - } - - @ToJson - public Map toJson(MetaDto metaDto) { - throw new UnsupportedOperationException(); - } - } - private static final class KnownTestsDto { private final Map>> tests; @@ -648,66 +599,4 @@ private TestManagementDto( this.branch = branch; } } - - private static final class TestManagementTestsDto { - private static final class Properties { - private final Map properties; - - private Properties(Map properties) { - this.properties = properties; - } - - public Boolean isQuarantined() { - return properties != null - ? properties.getOrDefault(TestSetting.QUARANTINED.asString(), false) - : false; - } - - public Boolean isDisabled() { - return properties != null - ? properties.getOrDefault(TestSetting.DISABLED.asString(), false) - : false; - } - - public Boolean isAttemptToFix() { - return properties != null - ? properties.getOrDefault(TestSetting.ATTEMPT_TO_FIX.asString(), false) - : false; - } - } - - private static final class Tests { - private final Map tests; - - private Tests(Map tests) { - this.tests = tests; - } - - public Map getTests() { - return tests != null ? tests : Collections.emptyMap(); - } - } - - private static final class Suites { - private final Map suites; - - private Suites(Map suites) { - this.suites = suites; - } - - public Map getSuites() { - return suites != null ? suites : Collections.emptyMap(); - } - } - - private final Map modules; - - private TestManagementTestsDto(Map modules) { - this.modules = modules; - } - - public Map getModules() { - return modules != null ? modules : Collections.emptyMap(); - } - } } diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/FileBasedConfigurationApi.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/FileBasedConfigurationApi.java new file mode 100644 index 00000000000..e4338c973be --- /dev/null +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/FileBasedConfigurationApi.java @@ -0,0 +1,312 @@ +package datadog.trace.civisibility.config; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import datadog.trace.api.civisibility.config.Configurations; +import datadog.trace.api.civisibility.config.TestFQN; +import datadog.trace.api.civisibility.config.TestIdentifier; +import datadog.trace.api.civisibility.config.TestMetadata; +import java.io.IOException; +import java.nio.file.Path; +import java.util.BitSet; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import okio.BufferedSource; +import okio.Okio; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implements {@link ConfigurationApi} by reading JSON files from disk instead of making HTTP + * requests. Each file is expected to contain the same JSON envelope structure that the backend API + * returns. + * + *

Paths that are {@code null} are treated as absent — the corresponding method returns a safe + * default (e.g. {@link CiVisibilitySettings#DEFAULT} or an empty collection). + */ +public class FileBasedConfigurationApi implements ConfigurationApi { + + private static final Logger LOGGER = LoggerFactory.getLogger(FileBasedConfigurationApi.class); + + @Nullable private final Path settingsPath; + @Nullable private final Path skippableTestsPath; + @Nullable private final Path flakyTestsPath; + @Nullable private final Path knownTestsPath; + @Nullable private final Path testManagementPath; + + private final JsonAdapter settingsAdapter; + private final JsonAdapter knownTestsAdapter; + private final JsonAdapter testManagementAdapter; + private final JsonAdapter testIdentifiersAdapter; + + public FileBasedConfigurationApi( + @Nullable Path settingsPath, + @Nullable Path skippableTestsPath, + @Nullable Path flakyTestsPath, + @Nullable Path knownTestsPath, + @Nullable Path testManagementPath) { + this.settingsPath = settingsPath; + this.skippableTestsPath = skippableTestsPath; + this.flakyTestsPath = flakyTestsPath; + this.knownTestsPath = knownTestsPath; + this.testManagementPath = testManagementPath; + + Moshi moshi = + new Moshi.Builder() + .add(ConfigurationsJsonAdapter.INSTANCE) + .add(CiVisibilitySettings.JsonAdapter.INSTANCE) + .add(EarlyFlakeDetectionSettings.JsonAdapter.INSTANCE) + .add(TestManagementSettings.JsonAdapter.INSTANCE) + .add(MetaDto.JsonAdapter.INSTANCE) + .build(); + + settingsAdapter = moshi.adapter(SettingsEnvelope.class); + knownTestsAdapter = moshi.adapter(KnownTestsEnvelope.class); + testManagementAdapter = moshi.adapter(TestManagementEnvelope.class); + testIdentifiersAdapter = moshi.adapter(TestIdentifiersEnvelope.class); + } + + @Override + public CiVisibilitySettings getSettings(TracerEnvironment tracerEnvironment) throws IOException { + if (settingsPath == null) { + LOGGER.debug("Settings file path not provided, returning defaults"); + return CiVisibilitySettings.DEFAULT; + } + + LOGGER.debug("Reading settings from {}", settingsPath); + try (BufferedSource source = Okio.buffer(Okio.source(settingsPath))) { + SettingsEnvelope envelope = settingsAdapter.fromJson(source); + if (envelope != null && envelope.data != null && envelope.data.attributes != null) { + return envelope.data.attributes; + } + } + return CiVisibilitySettings.DEFAULT; + } + + @Override + public SkippableTests getSkippableTests(TracerEnvironment tracerEnvironment) throws IOException { + if (skippableTestsPath == null) { + LOGGER.debug("Skippable tests file path not provided, returning empty"); + return SkippableTests.EMPTY; + } + + LOGGER.debug("Reading skippable tests from {}", skippableTestsPath); + try (BufferedSource source = Okio.buffer(Okio.source(skippableTestsPath))) { + TestIdentifiersEnvelope envelope = testIdentifiersAdapter.fromJson(source); + if (envelope != null && envelope.data != null) { + return toSkippableTests(envelope, tracerEnvironment); + } + } + return SkippableTests.EMPTY; + } + + private SkippableTests toSkippableTests( + TestIdentifiersEnvelope envelope, TracerEnvironment tracerEnvironment) { + Configurations requestConf = tracerEnvironment.getConfigurations(); + + Map> identifiersByModule = new HashMap<>(); + for (TestIdentifierDataDto dataDto : envelope.data) { + TestIdentifierJson testIdJson = dataDto.attributes; + if (testIdJson == null) { + continue; + } + Configurations conf = testIdJson.getConfigurations(); + String moduleName = + (conf != null && conf.getTestBundle() != null ? conf : requestConf).getTestBundle(); + identifiersByModule + .computeIfAbsent(moduleName, k -> new HashMap<>()) + .put(testIdJson.toTestIdentifier(), testIdJson.toTestMetadata()); + } + + String correlationId = envelope.meta != null ? envelope.meta.correlationId : null; + Map coverage = + envelope.meta != null && envelope.meta.coverage != null + ? envelope.meta.coverage + : Collections.emptyMap(); + return new SkippableTests(correlationId, identifiersByModule, coverage); + } + + @Override + public Map> getFlakyTestsByModule(TracerEnvironment tracerEnvironment) + throws IOException { + if (flakyTestsPath == null) { + LOGGER.debug("Flaky tests file path not provided, returning empty"); + return Collections.emptyMap(); + } + + LOGGER.debug("Reading flaky tests from {}", flakyTestsPath); + try (BufferedSource source = Okio.buffer(Okio.source(flakyTestsPath))) { + TestIdentifiersEnvelope envelope = testIdentifiersAdapter.fromJson(source); + if (envelope != null && envelope.data != null) { + return toFlakyTestsByModule(envelope, tracerEnvironment); + } + } + return Collections.emptyMap(); + } + + private Map> toFlakyTestsByModule( + TestIdentifiersEnvelope envelope, TracerEnvironment tracerEnvironment) { + Configurations requestConf = tracerEnvironment.getConfigurations(); + + Map> result = new HashMap<>(); + for (TestIdentifierDataDto dataDto : envelope.data) { + TestIdentifierJson testIdJson = dataDto.attributes; + if (testIdJson == null) { + continue; + } + Configurations conf = testIdJson.getConfigurations(); + String moduleName = + (conf != null && conf.getTestBundle() != null ? conf : requestConf).getTestBundle(); + result + .computeIfAbsent(moduleName, k -> new HashSet<>()) + .add(testIdJson.toTestIdentifier().toFQN()); + } + LOGGER.debug( + "Read {} flaky tests from file", result.values().stream().mapToInt(Collection::size).sum()); + return result; + } + + @Nullable + @Override + public Map> getKnownTestsByModule(TracerEnvironment tracerEnvironment) + throws IOException { + if (knownTestsPath == null) { + LOGGER.debug("Known tests file path not provided, returning empty"); + return Collections.emptyMap(); + } + + LOGGER.debug("Reading known tests from {}", knownTestsPath); + try (BufferedSource source = Okio.buffer(Okio.source(knownTestsPath))) { + KnownTestsEnvelope envelope = knownTestsAdapter.fromJson(source); + if (envelope != null + && envelope.data != null + && envelope.data.attributes != null + && envelope.data.attributes.tests != null) { + return parseKnownTests(envelope.data.attributes.tests); + } + } + return Collections.emptyMap(); + } + + private Map> parseKnownTests( + Map>> testsMap) { + int count = 0; + Map> result = new HashMap<>(); + for (Map.Entry>> moduleEntry : testsMap.entrySet()) { + String moduleName = moduleEntry.getKey(); + for (Map.Entry> suiteEntry : moduleEntry.getValue().entrySet()) { + String suiteName = suiteEntry.getKey(); + for (String testName : suiteEntry.getValue()) { + result + .computeIfAbsent(moduleName, k -> new HashSet<>()) + .add(new TestFQN(suiteName, testName)); + count++; + } + } + } + LOGGER.debug("Read {} known tests from file", count); + return count > 0 ? result : null; + } + + @Override + public Map>> getTestManagementTestsByModule( + TracerEnvironment tracerEnvironment, String commitSha, String commitMessage) + throws IOException { + if (testManagementPath == null) { + LOGGER.debug("Test management file path not provided, returning empty"); + return Collections.emptyMap(); + } + + LOGGER.debug("Reading test management data from {}", testManagementPath); + try (BufferedSource source = Okio.buffer(Okio.source(testManagementPath))) { + TestManagementEnvelope envelope = testManagementAdapter.fromJson(source); + if (envelope != null && envelope.data != null && envelope.data.attributes != null) { + return parseTestManagementTests(envelope.data.attributes); + } + } + return Collections.emptyMap(); + } + + private Map>> parseTestManagementTests( + TestManagementTestsDto dto) { + Map> quarantined = new HashMap<>(); + Map> disabled = new HashMap<>(); + Map> attemptToFix = new HashMap<>(); + + for (Map.Entry moduleEntry : + dto.getModules().entrySet()) { + String moduleName = moduleEntry.getKey(); + Map suites = moduleEntry.getValue().getSuites(); + + for (Map.Entry suiteEntry : suites.entrySet()) { + String suiteName = suiteEntry.getKey(); + Map tests = suiteEntry.getValue().getTests(); + + for (Map.Entry testEntry : tests.entrySet()) { + String testName = testEntry.getKey(); + TestManagementTestsDto.Properties props = testEntry.getValue(); + TestFQN fqn = new TestFQN(suiteName, testName); + + if (props.isQuarantined()) { + quarantined.computeIfAbsent(moduleName, k -> new HashSet<>()).add(fqn); + } + if (props.isDisabled()) { + disabled.computeIfAbsent(moduleName, k -> new HashSet<>()).add(fqn); + } + if (props.isAttemptToFix()) { + attemptToFix.computeIfAbsent(moduleName, k -> new HashSet<>()).add(fqn); + } + } + } + } + + Map>> result = new HashMap<>(); + result.put(TestSetting.QUARANTINED, quarantined); + result.put(TestSetting.DISABLED, disabled); + result.put(TestSetting.ATTEMPT_TO_FIX, attemptToFix); + return result; + } + + // Settings envelope: { "data": { "attributes": CiVisibilitySettings } } + static final class SettingsEnvelope { + DataDto data; + } + + // Known tests envelope: { "data": { "attributes": { "tests": ... } } } + static final class KnownTestsEnvelope { + DataDto data; + } + + // Test management envelope: { "data": { "attributes": TestManagementTestsDto } } + static final class TestManagementEnvelope { + DataDto data; + } + + // Shared single-item data wrapper + static final class DataDto { + String id; + String type; + T attributes; + } + + static final class KnownTestsAttributes { + Map>> tests; + } + + // Skippable/flaky tests envelope: { "data": [...], "meta": { ... } } + static final class TestIdentifiersEnvelope { + Collection data; + @Nullable MetaDto meta; + } + + static final class TestIdentifierDataDto { + String id; + String type; + TestIdentifierJson attributes; + } +} diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/MetaDto.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/MetaDto.java new file mode 100644 index 00000000000..ace87a71941 --- /dev/null +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/MetaDto.java @@ -0,0 +1,58 @@ +package datadog.trace.civisibility.config; + +import com.squareup.moshi.FromJson; +import com.squareup.moshi.ToJson; +import java.io.File; +import java.util.Base64; +import java.util.BitSet; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +final class MetaDto { + + @Nullable final String correlationId; + @Nullable final Map coverage; + + MetaDto(@Nullable String correlationId, @Nullable Map coverage) { + this.correlationId = correlationId; + this.coverage = coverage; + } + + static final class JsonAdapter { + + static final JsonAdapter INSTANCE = new JsonAdapter(); + + @SuppressWarnings("unchecked") + @FromJson + public MetaDto fromJson(Map json) { + if (json == null) { + return null; + } + + Map coverage; + Map encodedCoverage = (Map) json.get("coverage"); + if (encodedCoverage != null) { + coverage = new HashMap<>(); + for (Map.Entry e : encodedCoverage.entrySet()) { + String relativeSourceFilePath = e.getKey(); + String normalizedPath = + relativeSourceFilePath.startsWith(File.separator) + ? relativeSourceFilePath.substring(1) + : relativeSourceFilePath; + byte[] decodedLines = Base64.getDecoder().decode(e.getValue()); + coverage.put(normalizedPath, BitSet.valueOf(decodedLines)); + } + } else { + coverage = null; + } + + return new MetaDto((String) json.get("correlation_id"), coverage); + } + + @ToJson + public Map toJson(MetaDto metaDto) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/TestManagementTestsDto.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/TestManagementTestsDto.java new file mode 100644 index 00000000000..54293b436b4 --- /dev/null +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/config/TestManagementTestsDto.java @@ -0,0 +1,48 @@ +package datadog.trace.civisibility.config; + +import java.util.Collections; +import java.util.Map; +import javax.annotation.Nullable; + +final class TestManagementTestsDto { + + @Nullable Map modules; + + Map getModules() { + return modules != null ? modules : Collections.emptyMap(); + } + + static final class Properties { + @Nullable Map properties; + + boolean isQuarantined() { + return properties != null + && properties.getOrDefault(TestSetting.QUARANTINED.asString(), false); + } + + boolean isDisabled() { + return properties != null && properties.getOrDefault(TestSetting.DISABLED.asString(), false); + } + + boolean isAttemptToFix() { + return properties != null + && properties.getOrDefault(TestSetting.ATTEMPT_TO_FIX.asString(), false); + } + } + + static final class Tests { + @Nullable Map tests; + + Map getTests() { + return tests != null ? tests : Collections.emptyMap(); + } + } + + static final class Suites { + @Nullable Map suites; + + Map getSuites() { + return suites != null ? suites : Collections.emptyMap(); + } + } +} diff --git a/dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/config/FileBasedConfigurationApiTest.java b/dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/config/FileBasedConfigurationApiTest.java new file mode 100644 index 00000000000..8fd942cae3b --- /dev/null +++ b/dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/config/FileBasedConfigurationApiTest.java @@ -0,0 +1,282 @@ +package datadog.trace.civisibility.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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 datadog.trace.api.civisibility.config.TestFQN; +import datadog.trace.api.civisibility.config.TestIdentifier; +import datadog.trace.api.civisibility.config.TestMetadata; +import freemarker.template.Configuration; +import freemarker.template.Template; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Tests {@link FileBasedConfigurationApi} against the same response fixtures used by the HTTP + * variant ({@code ConfigurationApiImplTest}). Sharing the {@code *-response.ftl} templates keeps + * both code paths aligned — any change to the backend payload shape only needs to be reflected in + * one place. + */ +class FileBasedConfigurationApiTest { + + private static final String FIXTURE_DIR = "/datadog/trace/civisibility/config/"; + + private static final TracerEnvironment ENV = + TracerEnvironment.builder() + .service("foo") + .env("foo_env") + .repositoryUrl("https://github.com/DataDog/foo") + .branch("prod") + .sha("d64185e45d1722ab3a53c45be47accae") + .commitMessage("full commit message") + .build(); + + @Test + void returnsDefaultsWhenAllPathsAreNull() throws IOException { + FileBasedConfigurationApi api = new FileBasedConfigurationApi(null, null, null, null, null); + + assertSame(CiVisibilitySettings.DEFAULT, api.getSettings(ENV)); + assertSame(SkippableTests.EMPTY, api.getSkippableTests(ENV)); + assertEquals(0, api.getFlakyTestsByModule(ENV).size()); + assertEquals(0, api.getKnownTestsByModule(ENV).size()); + assertEquals(0, api.getTestManagementTestsByModule(ENV, null, null).size()); + } + + @Test + void parsesSettings(@TempDir Path tmp) throws IOException { + CiVisibilitySettings expected = + new CiVisibilitySettings( + true, + true, + true, + true, + true, + true, + true, + true, + true, + EarlyFlakeDetectionSettings.DEFAULT, + TestManagementSettings.DEFAULT, + "main", + false); + Map data = new HashMap<>(); + data.put("settings", expected); + Path file = renderToFile(tmp, "settings-response.ftl", "settings.json", data); + + FileBasedConfigurationApi api = new FileBasedConfigurationApi(file, null, null, null, null); + + assertEquals(expected, api.getSettings(ENV)); + } + + @Test + void parsesSettingsWithEarlyFlakeDetectionAndTestManagement(@TempDir Path tmp) + throws IOException { + CiVisibilitySettings expected = + new CiVisibilitySettings( + false, + true, + false, + true, + false, + true, + false, + false, + true, + new EarlyFlakeDetectionSettings( + true, Arrays.asList(new ExecutionsByDuration(1000, 3)), 10), + new TestManagementSettings(true, 10), + "master", + false); + Map data = new HashMap<>(); + data.put("settings", expected); + Path file = renderToFile(tmp, "settings-response.ftl", "settings.json", data); + + FileBasedConfigurationApi api = new FileBasedConfigurationApi(file, null, null, null, null); + + assertEquals(expected, api.getSettings(ENV)); + } + + @Test + void parsesSkippableTests(@TempDir Path tmp) throws IOException { + Path file = + renderToFile(tmp, "skippable-response.ftl", "skippable.json", Collections.emptyMap()); + + FileBasedConfigurationApi api = new FileBasedConfigurationApi(null, file, null, null, null); + SkippableTests result = api.getSkippableTests(ENV); + + Map> expected = new HashMap<>(); + Map bundleA = new HashMap<>(); + bundleA.put(new TestIdentifier("suite-a", "name-a", "parameters-a"), new TestMetadata(true)); + expected.put("testBundle-a", bundleA); + Map bundleB = new HashMap<>(); + bundleB.put(new TestIdentifier("suite-b", "name-b", null), new TestMetadata(false)); + expected.put("testBundle-b", bundleB); + + assertEquals(expected, result.getIdentifiersByModule()); + assertEquals("11223344", result.getCorrelationId()); + // coverage bitmaps encoded in the template + assertEquals(3, result.getCoveredLinesByRelativeSourcePath().size()); + assertTrue( + result.getCoveredLinesByRelativeSourcePath().containsKey("src/main/java/Calculator.java")); + assertTrue( + result.getCoveredLinesByRelativeSourcePath().containsKey("src/main/java/utils/Math.java")); + assertTrue( + result + .getCoveredLinesByRelativeSourcePath() + .containsKey("src/test/java/CalculatorTest.java")); + } + + @Test + void parsesFlakyTests(@TempDir Path tmp) throws IOException { + Path file = renderToFile(tmp, "flaky-response.ftl", "flaky.json", Collections.emptyMap()); + + FileBasedConfigurationApi api = new FileBasedConfigurationApi(null, null, file, null, null); + Map> result = api.getFlakyTestsByModule(ENV); + + Map> expected = new HashMap<>(); + expected.put("testBundle-a", new HashSet<>(Arrays.asList(new TestFQN("suite-a", "name-a")))); + expected.put("testBundle-b", new HashSet<>(Arrays.asList(new TestFQN("suite-b", "name-b")))); + assertEquals(expected, result); + } + + @Test + void parsesKnownTests(@TempDir Path tmp) throws IOException { + Path file = renderToFile(tmp, "known-tests-response.ftl", "known.json", Collections.emptyMap()); + + FileBasedConfigurationApi api = new FileBasedConfigurationApi(null, null, null, file, null); + Map> result = api.getKnownTestsByModule(ENV); + + assertNotNull(result); + assertEquals(2, result.size()); + Collection bundleA = result.get("test-bundle-a"); + assertTrue(bundleA.contains(new TestFQN("test-suite-a", "test-name-1"))); + assertTrue(bundleA.contains(new TestFQN("test-suite-a", "test-name-2"))); + assertTrue(bundleA.contains(new TestFQN("test-suite-b", "another-test-name-1"))); + assertTrue(bundleA.contains(new TestFQN("test-suite-b", "test-name-2"))); + Collection bundleN = result.get("test-bundle-N"); + assertTrue(bundleN.contains(new TestFQN("test-suite-M", "test-name-1"))); + assertTrue(bundleN.contains(new TestFQN("test-suite-M", "test-name-2"))); + } + + @Test + void knownTestsReturnsNullWhenResponseHasNoTests(@TempDir Path tmp) throws IOException { + // Matches the backend API contract: empty-but-present known-tests payload → null + Path file = writeText(tmp, "empty-known.json", "{\"data\":{\"attributes\":{\"tests\":{}}}}"); + + FileBasedConfigurationApi api = new FileBasedConfigurationApi(null, null, null, file, null); + + assertNull(api.getKnownTestsByModule(ENV)); + } + + @Test + void parsesTestManagement(@TempDir Path tmp) throws IOException { + Path file = + renderToFile( + tmp, "test-management-tests-response.ftl", "mgmt.json", Collections.emptyMap()); + + FileBasedConfigurationApi api = new FileBasedConfigurationApi(null, null, null, null, file); + Map>> result = + api.getTestManagementTestsByModule(ENV, ENV.getSha(), ENV.getCommitMessage()); + + Map> quarantined = new HashMap<>(); + quarantined.put( + "module-a", + new HashSet<>( + Arrays.asList(new TestFQN("suite-a", "test-a"), new TestFQN("suite-b", "test-c")))); + quarantined.put("module-b", new HashSet<>(Arrays.asList(new TestFQN("suite-c", "test-e")))); + assertEquals(quarantined, result.get(TestSetting.QUARANTINED)); + + Map> disabled = new HashMap<>(); + disabled.put("module-a", new HashSet<>(Arrays.asList(new TestFQN("suite-a", "test-b")))); + disabled.put( + "module-b", + new HashSet<>( + Arrays.asList(new TestFQN("suite-c", "test-d"), new TestFQN("suite-c", "test-f")))); + assertEquals(disabled, result.get(TestSetting.DISABLED)); + + Map> attemptToFix = new HashMap<>(); + attemptToFix.put("module-a", new HashSet<>(Arrays.asList(new TestFQN("suite-b", "test-c")))); + attemptToFix.put( + "module-b", + new HashSet<>( + Arrays.asList(new TestFQN("suite-c", "test-d"), new TestFQN("suite-c", "test-e")))); + assertEquals(attemptToFix, result.get(TestSetting.ATTEMPT_TO_FIX)); + } + + @Test + void propagatesIOExceptionForMissingFile(@TempDir Path tmp) { + Path missing = tmp.resolve("does-not-exist.json"); + FileBasedConfigurationApi api = new FileBasedConfigurationApi(missing, null, null, null, null); + + assertThrowsIO(() -> api.getSettings(ENV)); + } + + // --- helpers --- + + private static Path renderToFile( + Path dir, String templateName, String outputName, Map data) + throws IOException { + String content = render(templateName, data); + return writeText(dir, outputName, content); + } + + private static Path writeText(Path dir, String name, String content) throws IOException { + Path p = dir.resolve(name); + Files.write(p, content.getBytes(StandardCharsets.UTF_8)); + return p; + } + + private static final Configuration FREEMARKER; + + static { + FREEMARKER = new Configuration(Configuration.VERSION_2_3_30); + FREEMARKER.setClassLoaderForTemplateLoading( + FileBasedConfigurationApiTest.class.getClassLoader(), ""); + FREEMARKER.setDefaultEncoding("UTF-8"); + FREEMARKER.setLogTemplateExceptions(false); + FREEMARKER.setWrapUncheckedExceptions(true); + FREEMARKER.setFallbackOnNullLoopVariable(false); + FREEMARKER.setNumberFormat("0.######"); + } + + private static String render(String templateName, Map data) throws IOException { + Template template = FREEMARKER.getTemplate(FIXTURE_DIR + templateName); + StringWriter out = new StringWriter(); + try { + template.process(data, out); + } catch (freemarker.template.TemplateException e) { + throw new IOException("Failed to render template " + templateName, e); + } + return out.toString(); + } + + private static void assertThrowsIO(ThrowingRunnable runnable) { + try { + runnable.run(); + } catch (IOException expected) { + return; + } catch (Throwable t) { + throw new AssertionError("Expected IOException but got " + t, t); + } + throw new AssertionError("Expected IOException but none thrown"); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws IOException; + } +} diff --git a/dd-java-agent/instrumentation/junit/junit-4/junit-4.10/src/main/java/datadog/trace/instrumentation/junit4/JUnit4TracingListener.java b/dd-java-agent/instrumentation/junit/junit-4/junit-4.10/src/main/java/datadog/trace/instrumentation/junit4/JUnit4TracingListener.java index afa43e45493..fd6954d007b 100644 --- a/dd-java-agent/instrumentation/junit/junit-4/junit-4.10/src/main/java/datadog/trace/instrumentation/junit4/JUnit4TracingListener.java +++ b/dd-java-agent/instrumentation/junit/junit-4/junit-4.10/src/main/java/datadog/trace/instrumentation/junit4/JUnit4TracingListener.java @@ -8,8 +8,11 @@ import datadog.trace.bootstrap.ContextStore; import java.lang.reflect.Method; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.junit.Ignore; import org.junit.runner.Description; +import org.junit.runner.Result; import org.junit.runner.notification.Failure; public class JUnit4TracingListener extends TracingListener { @@ -19,6 +22,26 @@ public class JUnit4TracingListener extends TracingListener { private final ContextStore executionTrackers; + /** + * Suites for which {@code onTestSuiteStart} has been fired (from either the normal + * ParentRunner-based flow or via lazy-registration in {@link #testStarted}). Used to keep + * lifecycle events idempotent and to know which auto-started suite still needs closing. + */ + private final Set startedSuites = ConcurrentHashMap.newKeySet(); + + /** + * Last suite lazy-started from {@link #testStarted} because no {@link #testSuiteStarted} event + * was observed for it first. This has been seen under {@code + * com.google.testing.junit.runner.BazelTestRunner}, where the suite-start advice in {@code + * JUnit4SuiteEventsInstrumentation} does not fire for reasons still to be pinpointed (likely a + * classloader or runner-wrapping quirk specific to the Bazel test launcher). Closed when the next + * test belongs to a different suite, or when the whole test run finishes. + * + *

TODO: investigate the exact cause under {@code BazelTestRunner} and add a dedicated + * instrumentation that emits proper suite-lifecycle events instead of relying on this fallback. + */ + private volatile TestSuiteDescriptor autoStartedSuite; + public JUnit4TracingListener(ContextStore executionTrackers) { this.executionTrackers = executionTrackers; } @@ -32,6 +55,9 @@ public void testSuiteStarted(final Description description) { } TestSuiteDescriptor suiteDescriptor = JUnit4Utils.toSuiteDescriptor(description); + if (!startedSuites.add(suiteDescriptor)) { + return; // already started (idempotent vs. lazy-registration or duplicate events) + } Class testClass = description.getTestClass(); String testSuiteName = JUnit4Utils.getSuiteName(testClass, description); List categories = JUnit4Utils.getCategories(testClass, null); @@ -58,6 +84,9 @@ public void testSuiteFinished(final Description description) { } TestSuiteDescriptor suiteDescriptor = JUnit4Utils.toSuiteDescriptor(description); + if (!startedSuites.remove(suiteDescriptor)) { + return; // never started + } TestEventsHandlerHolder.HANDLERS .get(TestFrameworkInstrumentation.JUNIT4) .onTestSuiteFinish(suiteDescriptor, null); @@ -73,6 +102,8 @@ public void testStarted(final Description description) { TestDescriptor testDescriptor = JUnit4Utils.toTestDescriptor(description); TestSourceData testSourceData = JUnit4Utils.toTestSourceData(description); + lazyStartSuiteIfNeeded(suiteDescriptor, description, testSourceData); + String testName = JUnit4Utils.getTestName(description, testSourceData.getTestMethod()); String testParameters = JUnit4Utils.getParameters(description); List categories = @@ -93,6 +124,50 @@ public void testStarted(final Description description) { executionTrackers.get(description)); } + @Override + public void testRunFinished(Result result) { + closeAutoStartedSuite(); + } + + private void lazyStartSuiteIfNeeded( + TestSuiteDescriptor newSuite, Description description, TestSourceData testSourceData) { + if (startedSuites.contains(newSuite)) { + return; + } + closeAutoStartedSuite(); + + Class testClass = testSourceData.getTestClass(); + String testSuiteName = JUnit4Utils.getSuiteName(testClass, description); + List categories = JUnit4Utils.getCategories(testClass, null); + TestEventsHandlerHolder.HANDLERS + .get(TestFrameworkInstrumentation.JUNIT4) + .onTestSuiteStart( + newSuite, + testSuiteName, + FRAMEWORK_NAME, + FRAMEWORK_VERSION, + testClass, + categories, + false, + TestFrameworkInstrumentation.JUNIT4, + null); + startedSuites.add(newSuite); + autoStartedSuite = newSuite; + } + + private void closeAutoStartedSuite() { + TestSuiteDescriptor suite = autoStartedSuite; + if (suite == null) { + return; + } + autoStartedSuite = null; + if (startedSuites.remove(suite)) { + TestEventsHandlerHolder.HANDLERS + .get(TestFrameworkInstrumentation.JUNIT4) + .onTestSuiteFinish(suite, null); + } + } + @Override public void testFinished(final Description description) { if (JUnit4Utils.isJUnitPlatformRunnerTest(description)) { diff --git a/dd-java-agent/instrumentation/junit/junit-4/junit-4.10/src/main/java/datadog/trace/instrumentation/junit4/JUnit4Utils.java b/dd-java-agent/instrumentation/junit/junit-4/junit-4.10/src/main/java/datadog/trace/instrumentation/junit4/JUnit4Utils.java index 9b46dc9b351..697c38d56e2 100644 --- a/dd-java-agent/instrumentation/junit/junit-4/junit-4.10/src/main/java/datadog/trace/instrumentation/junit4/JUnit4Utils.java +++ b/dd-java-agent/instrumentation/junit/junit-4/junit-4.10/src/main/java/datadog/trace/instrumentation/junit4/JUnit4Utils.java @@ -42,6 +42,17 @@ public abstract class JUnit4Utils { private static final String SYNCHRONIZED_LISTENER = "org.junit.runner.notification.SynchronizedRunListener"; + /** + * Bazel's test launcher wraps the {@link RunNotifier} that our instrumentation advice receives. + * {@link RunNotifier#addListener} on the wrapper forwards to its inner delegate, but {@link + * org.junit.runner.notification.RunNotifier#listeners} on the wrapper is a separate (empty) + * field. Without unwrapping, our idempotency check fails to see a listener installed via a prior + * advice call on the inner notifier, and we end up adding a second tracing listener that the + * wrapper also forwards to the delegate. + */ + private static final String BAZEL_RUN_NOTIFIER_WRAPPER = + "com.google.testing.junit.junit4.runner.RunNotifierWrapper"; + // Regex for the final brackets with its content in the test name. E.g. test_name[0] --> [0] private static final Pattern testNameNormalizerRegex = Pattern.compile("\\[[^\\[]*\\]$"); @@ -55,6 +66,8 @@ public abstract class JUnit4Utils { private static final MethodHandle RUN_NOTIFIER_LISTENERS = accessListenersFieldInRunNotifier(); private static final MethodHandle INNER_SYNCHRONIZED_LISTENER = accessListenerFieldInSynchronizedListener(); + private static final MethodHandle BAZEL_RUN_NOTIFIER_WRAPPER_DELEGATE = + accessDelegateFieldInBazelRunNotifierWrapper(); private static final MethodHandle DESCRIPTION_UNIQUE_ID = METHOD_HANDLES.privateFieldGetter(Description.class, "fUniqueId"); @@ -89,8 +102,47 @@ private static MethodHandle accessListenerFieldInSynchronizedListener() { .privateFieldGetter(SYNCHRONIZED_LISTENER, "listener"); } + private static MethodHandle accessDelegateFieldInBazelRunNotifierWrapper() { + MethodHandle handle = METHOD_HANDLES.privateFieldGetter(BAZEL_RUN_NOTIFIER_WRAPPER, "delegate"); + if (handle != null) { + return handle; + } + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + return new MethodHandles(contextClassLoader) + .privateFieldGetter(BAZEL_RUN_NOTIFIER_WRAPPER, "delegate"); + } + public static List runListenersFromRunNotifier(final RunNotifier runNotifier) { - return METHOD_HANDLES.invoke(RUN_NOTIFIER_LISTENERS, runNotifier); + return METHOD_HANDLES.invoke(RUN_NOTIFIER_LISTENERS, unwrapRunNotifier(runNotifier)); + } + + /** + * Walks through {@link RunNotifier} wrappers (e.g. Bazel's {@code RunNotifierWrapper}) so the + * effective {@code listeners} field is read, not the wrapper's own (forwarded) one. + */ + private static RunNotifier unwrapRunNotifier(RunNotifier notifier) { + RunNotifier current = notifier; + for (int i = 0; i < 8 && current != null; i++) { + if (!isBazelRunNotifierWrapper(current.getClass())) { + return current; + } + RunNotifier delegate = METHOD_HANDLES.invoke(BAZEL_RUN_NOTIFIER_WRAPPER_DELEGATE, current); + if (delegate == null || delegate == current) { + return current; + } + current = delegate; + } + return current; + } + + private static boolean isBazelRunNotifierWrapper(Class cls) { + while (cls != null && cls != Object.class) { + if (BAZEL_RUN_NOTIFIER_WRAPPER.equals(cls.getName())) { + return true; + } + cls = cls.getSuperclass(); + } + return false; } public static TracingListener toTracingListener(final RunListener listener) { 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 457ca333b84..05a1075f57d 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 @@ -110,5 +110,13 @@ public final class CiVisibilityConfig { public static final String TEST_SESSION_NAME = "test.session.name"; + /* Bazel support */ + /** Path to the manifest file that enables reading configuration from local JSON files. */ + public static final String TEST_OPTIMIZATION_MANIFEST_FILE = "test.optimization.manifest.file"; + + /** When true, spans are serialized to JSON files on disk instead of sent over the network. */ + public static final String TEST_OPTIMIZATION_PAYLOADS_IN_FILES = + "test.optimization.payloads.in.files"; + private CiVisibilityConfig() {} } diff --git a/dd-trace-core/src/main/java/datadog/trace/common/writer/FileBasedPayloadDispatcher.java b/dd-trace-core/src/main/java/datadog/trace/common/writer/FileBasedPayloadDispatcher.java new file mode 100644 index 00000000000..1339224ccd2 --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/common/writer/FileBasedPayloadDispatcher.java @@ -0,0 +1,410 @@ +package datadog.trace.common.writer; + +import static datadog.json.JsonMapper.toJson; + +import datadog.json.JsonWriter; +import datadog.trace.api.Config; +import datadog.trace.api.DDTraceId; +import datadog.trace.api.TagMap; +import datadog.trace.api.civisibility.CiVisibilityWellKnownTags; +import datadog.trace.api.civisibility.coverage.TestReport; +import datadog.trace.api.civisibility.coverage.TestReportFileEntry; +import datadog.trace.api.civisibility.coverage.TestReportHolder; +import datadog.trace.api.civisibility.domain.TestContext; +import datadog.trace.api.gateway.RequestContextSlot; +import datadog.trace.api.intake.TrackType; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.core.CoreSpan; +import datadog.trace.core.Metadata; +import datadog.trace.core.MetadataConsumer; +import datadog.trace.util.PidHelper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.BitSet; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link PayloadDispatcher} that writes CI Visibility payloads as JSON files to a directory, + * instead of sending them over the network. Used in Bazel's hermetic sandbox where network access + * is forbidden. + * + *

Each flush produces a single JSON file named {@code {kind}-{timestamp_ns}-{pid}-{seq}.json}, + * written atomically via a temp file + rename. + * + *

TODO: unify serialization with msgpack mappers via a format-agnostic abstraction + */ +public class FileBasedPayloadDispatcher implements PayloadDispatcher { + + private static final Logger log = LoggerFactory.getLogger(FileBasedPayloadDispatcher.class); + + private static final Collection TOP_LEVEL_TAGS = + Arrays.asList( + Tags.TEST_SESSION_ID, Tags.TEST_MODULE_ID, Tags.TEST_SUITE_ID, Tags.ITR_CORRELATION_ID); + + /** Tag prefixes excluded from file-based payloads to avoid Bazel cache invalidation. */ + private static final String[] EXCLUDED_TAG_PREFIXES = {"ci.", "git.", "runtime.", "os."}; + + private static final Set EXCLUDED_TAGS = + new HashSet<>(Arrays.asList("runtime-id", "pr.number")); + + private final Path outputDir; + private final String filePrefix; + private final TrackType trackType; + private final CiVisibilityWellKnownTags wellKnownTags = + Config.get().getCiVisibilityWellKnownTags(); + private final List serializedEvents = new ArrayList<>(); + private final AtomicLong sequence = new AtomicLong(0); + + public FileBasedPayloadDispatcher(Path outputDir, String filePrefix, TrackType trackType) { + this.outputDir = outputDir; + this.filePrefix = filePrefix; + this.trackType = trackType; + } + + private static TestReport getTestReport(CoreSpan span) { + if (span instanceof AgentSpan) { + TestContext test = + ((AgentSpan) span).getRequestContext().getData(RequestContextSlot.CI_VISIBILITY); + if (test != null) { + TestReportHolder probes = test.getCoverageStore(); + if (probes != null) { + return probes.getReport(); + } + } + } + return null; + } + + private static boolean isExcludedTag(String key) { + if (EXCLUDED_TAGS.contains(key)) { + return true; + } + for (String prefix : EXCLUDED_TAG_PREFIXES) { + if (key.startsWith(prefix)) { + return true; + } + } + return false; + } + + private static boolean charSeqEquals(CharSequence a, CharSequence b) { + return a == null && b == null + || a != null && b != null && Objects.equals(a.toString(), b.toString()); + } + + @Override + public void onDroppedTrace(int spanCount) { + // no-op + } + + // -- Test event serialization (mirrors CiTestCycleMapperV1.map) -- + + @Override + public void addTrace(List> trace) { + if (trace.isEmpty()) { + return; + } + + if (trackType == TrackType.CITESTCYCLE) { + for (CoreSpan span : trace) { + String json = serializeTestEvent(span); + if (json != null) { + serializedEvents.add(json); + } + } + } else if (trackType == TrackType.CITESTCOV) { + for (CoreSpan span : trace) { + String json = serializeCoverageEvent(span); + if (json != null) { + serializedEvents.add(json); + } + } + } + } + + // -- Coverage event serialization (mirrors CiTestCovMapperV2.map) -- + + @Override + public void flush() { + if (serializedEvents.isEmpty()) { + return; + } + + try { + ensureOutputDir(); + + JsonWriter doc = new JsonWriter(false); + doc.beginObject(); + + if (trackType == TrackType.CITESTCYCLE) { + doc.name("version").value(1); + doc.name("metadata"); + doc.beginObject(); + doc.name("*"); + doc.beginObject(); + doc.name("env").value(wellKnownTags.getEnv().toString()); + doc.name("language").value(wellKnownTags.getLanguage().toString()); + doc.name("test_is_user_provided_service") + .value(wellKnownTags.getIsUserProvidedService().toString()); + doc.endObject(); + doc.endObject(); + doc.name("events"); + } else { + doc.name("version").value(2); + doc.name("coverages"); + } + + doc.beginArray(); + for (String event : serializedEvents) { + doc.jsonValue(event); + } + doc.endArray(); + doc.endObject(); + + writeFileAtomically(doc.toByteArray()); + } catch (IOException e) { + log.error("[bazel mode] Failed to write payload file to {}", outputDir, e); + } finally { + serializedEvents.clear(); + } + } + + @Override + public Collection getApis() { + return Collections.emptyList(); + } + + // -- Tag writing -- + + private String serializeTestEvent(CoreSpan span) { + DDTraceId testSessionId = span.getTag(Tags.TEST_SESSION_ID); + Number testModuleId = span.getTag(Tags.TEST_MODULE_ID); + Number testSuiteId = span.getTag(Tags.TEST_SUITE_ID); + String itrCorrelationId = span.getTag(Tags.ITR_CORRELATION_ID); + + CharSequence type; + Long traceId; + Long spanId; + Long parentId; + int version; + CharSequence spanType = span.getType(); + if (charSeqEquals(InternalSpanTypes.TEST, spanType)) { + type = InternalSpanTypes.TEST; + traceId = span.getTraceId().toLong(); + spanId = span.getSpanId(); + parentId = span.getParentId(); + version = (testSessionId != null || testModuleId != null || testSuiteId != null) ? 2 : 1; + } else if (charSeqEquals(InternalSpanTypes.TEST_SUITE_END, spanType)) { + type = InternalSpanTypes.TEST_SUITE_END; + traceId = null; + spanId = null; + parentId = null; + version = 1; + } else if (charSeqEquals(InternalSpanTypes.TEST_MODULE_END, spanType)) { + type = InternalSpanTypes.TEST_MODULE_END; + traceId = null; + spanId = null; + parentId = null; + version = 1; + } else if (charSeqEquals(InternalSpanTypes.TEST_SESSION_END, spanType)) { + type = InternalSpanTypes.TEST_SESSION_END; + traceId = null; + spanId = null; + parentId = null; + version = 1; + } else { + type = "span"; + traceId = span.getTraceId().toLong(); + spanId = span.getSpanId(); + parentId = span.getParentId(); + version = 1; + } + + JsonWriter w = new JsonWriter(false); + w.beginObject(); + w.name("type").value(type.toString()); + w.name("version").value(version); + w.name("content"); + w.beginObject(); + + // trace/span/parent ids are unsigned 64-bit integers; emit as raw JSON numbers + // (value(long) would reinterpret ids >= 2^63 as negative signed longs). + if (traceId != null) { + w.name("trace_id").jsonValue(Long.toUnsignedString(traceId)); + } + if (spanId != null) { + w.name("span_id").jsonValue(Long.toUnsignedString(spanId)); + } + if (parentId != null) { + w.name("parent_id").jsonValue(Long.toUnsignedString(parentId)); + } + if (testSessionId != null) { + w.name(Tags.TEST_SESSION_ID).value(testSessionId.toLong()); + } + if (testModuleId != null) { + w.name(Tags.TEST_MODULE_ID).value(testModuleId.longValue()); + } + if (testSuiteId != null) { + w.name(Tags.TEST_SUITE_ID).value(testSuiteId.longValue()); + } + if (itrCorrelationId != null) { + w.name(Tags.ITR_CORRELATION_ID).value(itrCorrelationId); + } + + w.name("service").value(span.getServiceName()); + w.name("name").value(String.valueOf(span.getOperationName())); + w.name("resource").value(String.valueOf(span.getResourceName())); + w.name("start").value(span.getStartTime()); + w.name("duration").value(span.getDurationNano()); + w.name("error").value(span.getError()); + + span.processTagsAndBaggage(new JsonMetaWriter(w)); + + w.endObject(); // content + w.endObject(); // event + return w.toString(); + } + + private String serializeCoverageEvent(CoreSpan span) { + CharSequence type = span.getType(); + if (type == null || !type.toString().contentEquals(InternalSpanTypes.TEST)) { + return null; + } + + TestReport testReport = getTestReport(span); + if (testReport == null || !testReport.isNotEmpty()) { + return null; + } + + JsonWriter w = new JsonWriter(false); + w.beginObject(); + + DDTraceId testSessionId = testReport.getTestSessionId(); + if (testSessionId != null) { + w.name("test_session_id").value(testSessionId.toLong()); + } + Long testSuiteId = testReport.getTestSuiteId(); + if (testSuiteId != null) { + w.name("test_suite_id").value(testSuiteId); + } + w.name("span_id").value(testReport.getSpanId()); + + w.name("files"); + w.beginArray(); + for (TestReportFileEntry entry : testReport.getTestReportFileEntries()) { + w.beginObject(); + w.name("filename").value(entry.getSourceFileName()); + BitSet coveredLines = entry.getCoveredLines(); + if (coveredLines != null) { + w.name("bitmap").value(Base64.getEncoder().encodeToString(coveredLines.toByteArray())); + } + w.endObject(); + } + w.endArray(); + + w.endObject(); + return w.toString(); + } + + // -- File I/O -- + + private void ensureOutputDir() throws IOException { + if (!Files.exists(outputDir)) { + Files.createDirectories(outputDir); + } + } + + private void writeFileAtomically(byte[] data) throws IOException { + long timestampNs = System.nanoTime(); + long pid = PidHelper.getPidAsLong(); + long seq = sequence.getAndIncrement(); + + String filename = String.format("%s-%d-%d-%d.json", filePrefix, timestampNs, pid, seq); + Path target = outputDir.resolve(filename); + Path tmp = outputDir.resolve(filename + ".tmp"); + + Files.write(tmp, data); + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE); + + if (log.isDebugEnabled()) { + log.debug("[bazel mode] Wrote payload file: {} ({} bytes)", target, data.length); + } + } + + /** Writes span meta/metrics as JSON, filtering out CI/Git/OS/Runtime tags. */ + private static final class JsonMetaWriter implements MetadataConsumer { + private final JsonWriter w; + + JsonMetaWriter(JsonWriter w) { + this.w = w; + } + + private static void writeNumber(JsonWriter w, Number n) { + if (n instanceof Double) { + w.value(n.doubleValue()); + } else if (n instanceof Float) { + w.value(n.floatValue()); + } else { + w.value(n.longValue()); + } + } + + @Override + @SuppressWarnings("unchecked") + public void accept(Metadata metadata) { + TagMap tags = metadata.getTags().copy(); + for (String topLevel : TOP_LEVEL_TAGS) { + tags.remove(topLevel); + } + + w.name("metrics"); + w.beginObject(); + for (Map.Entry entry : tags.entrySet()) { + if (entry.getValue() instanceof Number && !isExcludedTag(entry.getKey())) { + w.name(entry.getKey()); + writeNumber(w, (Number) entry.getValue()); + } + } + w.endObject(); + + w.name("meta"); + w.beginObject(); + for (Map.Entry entry : metadata.getBaggage().entrySet()) { + if (!isExcludedTag(entry.getKey())) { + w.name(entry.getKey()).value(entry.getValue()); + } + } + if (metadata.getHttpStatusCode() != null) { + w.name(Tags.HTTP_STATUS).value(metadata.getHttpStatusCode().toString()); + } + for (Map.Entry entry : tags.entrySet()) { + Object value = entry.getValue(); + if (!(value instanceof Number) && !isExcludedTag(entry.getKey())) { + w.name(entry.getKey()); + if (value instanceof Iterable) { + w.value(toJson((Collection) value)); + } else { + w.value(String.valueOf(value)); + } + } + } + w.endObject(); + } + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/common/writer/WriterFactory.java b/dd-trace-core/src/main/java/datadog/trace/common/writer/WriterFactory.java index 709de57b69f..137d841bd08 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/writer/WriterFactory.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/writer/WriterFactory.java @@ -12,8 +12,10 @@ import datadog.common.container.ServerlessInfo; import datadog.communication.ddagent.DDAgentFeaturesDiscovery; +import datadog.communication.ddagent.DroppingPolicy; import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.trace.api.Config; +import datadog.trace.api.civisibility.config.BazelMode; import datadog.trace.api.intake.TrackType; import datadog.trace.common.sampling.Sampler; import datadog.trace.common.sampling.SingleSpanSampler; @@ -25,7 +27,9 @@ import datadog.trace.core.monitor.HealthMetrics; import datadog.trace.util.Strings; import de.thetaphi.forbiddenapis.SuppressForbidden; +import java.nio.file.Path; import java.util.concurrent.TimeUnit; +import javax.annotation.Nonnull; import okhttp3.HttpUrl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -83,6 +87,35 @@ public static Writer createWriter( int flushIntervalMilliseconds = Math.round(config.getTraceFlushIntervalSeconds() * 1000); DDAgentFeaturesDiscovery featuresDiscovery = commObjects.featuresDiscovery(config); + // CI Visibility with bazel support wants to write traces into JSON files + if (config.isCiVisibilityEnabled() && BazelMode.get().isPayloadFilesEnabled()) { + BazelMode bazelMode = BazelMode.get(); + Path testsDir = bazelMode.getTestPayloadsDir(); + Path coverageDir = + config.isCiVisibilityCodeCoverageEnabled() ? bazelMode.getCoveragePayloadsDir() : null; + if (testsDir != null) { + log.info( + "[bazel mode] Payloads-in-files enabled, writing to {}", bazelMode.getPayloadsDir()); + + PayloadDispatcher dispatcher = getPayloadDispatcher(testsDir, coverageDir); + + TraceProcessingWorker worker = + new TraceProcessingWorker( + 1024, + healthMetrics, + dispatcher, + DroppingPolicy.DISABLED, + prioritization, + flushIntervalMilliseconds, + TimeUnit.MILLISECONDS, + singleSpanSampler); + + return new DDIntakeWriter(worker, dispatcher, healthMetrics, 5, TimeUnit.SECONDS, false); + } + log.warn( + "[bazel mode] Payloads-in-files mode enabled but payload directory not resolved, falling back to default writer"); + } + // The AgentWriter doesn't support the CI Visibility protocol. If CI Visibility is // enabled, check if we can use the IntakeWriter instead. if (DD_AGENT_WRITER_TYPE.equals(configuredType) && (config.isCiVisibilityEnabled())) { @@ -174,6 +207,22 @@ public static Writer createWriter( return remoteWriter; } + @Nonnull + private static PayloadDispatcher getPayloadDispatcher(Path testsDir, Path coverageDir) { + FileBasedPayloadDispatcher testDispatcher = + new FileBasedPayloadDispatcher(testsDir, "tests", TrackType.CITESTCYCLE); + + PayloadDispatcher dispatcher; + if (coverageDir != null) { + FileBasedPayloadDispatcher covDispatcher = + new FileBasedPayloadDispatcher(coverageDir, "coverage", TrackType.CITESTCOV); + dispatcher = new CompositePayloadDispatcher(testDispatcher, covDispatcher); + } else { + dispatcher = testDispatcher; + } + return dispatcher; + } + private static RemoteApi createDDIntakeRemoteApi( Config config, SharedCommunicationObjects commObjects, diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java index adfd16bd257..ed611b978c6 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java @@ -40,6 +40,7 @@ import datadog.trace.api.Pair; import datadog.trace.api.TagMap; import datadog.trace.api.TraceConfig; +import datadog.trace.api.civisibility.config.BazelMode; import datadog.trace.api.datastreams.AgentDataStreamsMonitoring; import datadog.trace.api.datastreams.PathwayContext; import datadog.trace.api.experimental.DataStreamsCheckpointer; @@ -763,7 +764,9 @@ private CoreTracer( } if (config.isCiVisibilityEnabled() - && (config.isCiVisibilityAgentlessEnabled() || featuresDiscovery.supportsEvpProxy())) { + && (config.isCiVisibilityAgentlessEnabled() + || featuresDiscovery.supportsEvpProxy() + || BazelMode.get().isPayloadFilesEnabled())) { pendingTraceBuffer = PendingTraceBuffer.discarding(); traceCollectorFactory = new StreamingTraceCollector.Factory(this, this.timeSource, this.healthMetrics); @@ -822,7 +825,7 @@ private CoreTracer( addTraceInterceptor(CiVisibilityTraceInterceptor.INSTANCE); } - if (config.isCiVisibilityAgentlessEnabled()) { + if (config.isCiVisibilityAgentlessEnabled() || BazelMode.get().isPayloadFilesEnabled()) { addTraceInterceptor(DDIntakeTraceInterceptor.INSTANCE); } else { featuresDiscovery.discoverIfOutdated(); 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 new file mode 100644 index 00000000000..ab41b649fe9 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/common/writer/FileBasedPayloadDispatcherTest.java @@ -0,0 +1,297 @@ +package datadog.trace.common.writer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import datadog.trace.api.DDTraceId; +import datadog.trace.api.TagMap; +import datadog.trace.api.intake.TrackType; +import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import datadog.trace.core.CoreSpan; +import datadog.trace.core.Metadata; +import datadog.trace.core.MetadataConsumer; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileBasedPayloadDispatcherTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void flushWithNoEventsIsNoOp(@TempDir Path outputDir) throws IOException { + FileBasedPayloadDispatcher dispatcher = + new FileBasedPayloadDispatcher(outputDir, "tests", TrackType.CITESTCYCLE); + + dispatcher.flush(); + + assertTrue(listFiles(outputDir).isEmpty()); + } + + @Test + void addTraceIsNoOpForEmptyTraces(@TempDir Path outputDir) throws IOException { + FileBasedPayloadDispatcher dispatcher = + new FileBasedPayloadDispatcher(outputDir, "tests", TrackType.CITESTCYCLE); + + dispatcher.addTrace(Collections.emptyList()); + dispatcher.flush(); + + assertTrue(listFiles(outputDir).isEmpty()); + } + + @Test + void onDroppedTraceIsNoOpAndGetApisReturnsEmpty(@TempDir Path outputDir) { + FileBasedPayloadDispatcher dispatcher = + new FileBasedPayloadDispatcher(outputDir, "tests", TrackType.CITESTCYCLE); + + assertTrue(dispatcher.getApis().isEmpty()); + dispatcher.onDroppedTrace(42); // does not throw + } + + @Test + void citestcycleFlushWritesEnvelopeWithVersionMetadataAndEvents(@TempDir Path outputDir) + throws IOException { + FileBasedPayloadDispatcher dispatcher = + new FileBasedPayloadDispatcher(outputDir, "tests", TrackType.CITESTCYCLE); + Map tags = new HashMap<>(); + tags.put(Tags.TEST_SESSION_ID, DDTraceId.from(123)); + tags.put(Tags.TEST_MODULE_ID, 456L); + tags.put(Tags.TEST_SUITE_ID, 789L); + tags.put(Tags.ITR_CORRELATION_ID, "corr-1"); + CoreSpan span = mockSpan(InternalSpanTypes.TEST, tags); + + dispatcher.addTrace(Collections.singletonList(span)); + dispatcher.flush(); + + List files = listFiles(outputDir); + assertEquals(1, files.size()); + String filename = files.get(0).getFileName().toString(); + assertTrue(filename.startsWith("tests-")); + assertTrue(filename.endsWith(".json")); + + JsonNode doc = JSON.readTree(files.get(0).toFile()); + assertEquals(1, doc.get("version").asInt()); + assertTrue(doc.has("metadata")); + assertTrue(doc.get("metadata").has("*")); + assertTrue(doc.get("events").isArray()); + assertEquals(1, doc.get("events").size()); + + JsonNode event = doc.get("events").get(0); + assertEquals("test", event.get("type").asText()); + assertEquals(2, event.get("version").asInt()); // has session/module/suite id + JsonNode content = event.get("content"); + assertEquals(123L, content.get(Tags.TEST_SESSION_ID).asLong()); + assertEquals(456L, content.get(Tags.TEST_MODULE_ID).asLong()); + assertEquals(789L, content.get(Tags.TEST_SUITE_ID).asLong()); + assertEquals("corr-1", content.get(Tags.ITR_CORRELATION_ID).asText()); + // trace/span/parent ids must be JSON numbers (backend schema rejects strings) + assertTrue(content.get("trace_id").isNumber(), "trace_id should be a JSON number"); + assertTrue(content.get("span_id").isNumber(), "span_id should be a JSON number"); + assertTrue(content.get("parent_id").isNumber(), "parent_id should be a JSON number"); + } + + @Test + void citestcycleStripsCiGitOsRuntimeTagsAndWellKnownFields(@TempDir Path outputDir) + throws IOException { + FileBasedPayloadDispatcher dispatcher = + new FileBasedPayloadDispatcher(outputDir, "tests", TrackType.CITESTCYCLE); + Map tags = new HashMap<>(); + tags.put("ci.provider.name", "github"); + tags.put("git.branch", "main"); + tags.put("os.name", "linux"); + tags.put("runtime.name", "openjdk"); + tags.put("runtime-id", "uuid"); + tags.put("pr.number", "42"); + tags.put("custom.tag", "kept"); + tags.put("kept.metric", 99L); + CoreSpan span = mockSpan(InternalSpanTypes.TEST, tags); + + dispatcher.addTrace(Collections.singletonList(span)); + dispatcher.flush(); + + JsonNode doc = JSON.readTree(listFiles(outputDir).get(0).toFile()); + JsonNode content = doc.get("events").get(0).get("content"); + JsonNode meta = content.get("meta"); + JsonNode metrics = content.get("metrics"); + + assertFalse(meta.has("ci.provider.name")); + assertFalse(meta.has("git.branch")); + assertFalse(meta.has("os.name")); + assertFalse(meta.has("runtime.name")); + assertFalse(meta.has("runtime-id")); + assertFalse(meta.has("pr.number")); + + assertEquals("kept", meta.get("custom.tag").asText()); + assertEquals(99L, metrics.get("kept.metric").asLong()); + } + + @Test + void citestcycleAssignsEventTypesForSessionModuleSuiteTestSpanSpans(@TempDir Path outputDir) + throws IOException { + FileBasedPayloadDispatcher dispatcher = + new FileBasedPayloadDispatcher(outputDir, "tests", TrackType.CITESTCYCLE); + List> spans = + Arrays.asList( + mockSpan(InternalSpanTypes.TEST_SESSION_END, Collections.emptyMap()), + mockSpan(InternalSpanTypes.TEST_MODULE_END, Collections.emptyMap()), + mockSpan(InternalSpanTypes.TEST_SUITE_END, Collections.emptyMap()), + mockSpan(InternalSpanTypes.TEST, Collections.emptyMap()), + mockSpan("other-span-type", Collections.emptyMap())); + + dispatcher.addTrace(spans); + dispatcher.flush(); + + JsonNode events = JSON.readTree(listFiles(outputDir).get(0).toFile()).get("events"); + assertEquals(5, events.size()); + assertEquals(InternalSpanTypes.TEST_SESSION_END.toString(), events.get(0).get("type").asText()); + assertEquals(InternalSpanTypes.TEST_MODULE_END.toString(), events.get(1).get("type").asText()); + assertEquals(InternalSpanTypes.TEST_SUITE_END.toString(), events.get(2).get("type").asText()); + assertEquals(InternalSpanTypes.TEST.toString(), events.get(3).get("type").asText()); + assertEquals("span", events.get(4).get("type").asText()); + // session/module/suite events do not have trace/span/parent ids + assertFalse(events.get(0).get("content").has("trace_id")); + assertFalse(events.get(1).get("content").has("trace_id")); + assertFalse(events.get(2).get("content").has("trace_id")); + // test and span events do + assertTrue(events.get(3).get("content").has("trace_id")); + assertTrue(events.get(4).get("content").has("trace_id")); + } + + @Test + void citestcovSkipsNonTestSpansAndEmptyReports(@TempDir Path outputDir) throws IOException { + FileBasedPayloadDispatcher dispatcher = + new FileBasedPayloadDispatcher(outputDir, "coverage", TrackType.CITESTCOV); + CoreSpan nonTest = mockSpan("not-a-test", Collections.emptyMap()); + CoreSpan testNoCoverage = mockSpan(InternalSpanTypes.TEST, Collections.emptyMap()); + + dispatcher.addTrace(Arrays.asList(nonTest, testNoCoverage)); + dispatcher.flush(); + + assertTrue(listFiles(outputDir).isEmpty()); + } + + @Test + void filenameFollowsPrefixNsPidSeqConvention(@TempDir Path outputDir) throws IOException { + FileBasedPayloadDispatcher dispatcher = + new FileBasedPayloadDispatcher(outputDir, "tests", TrackType.CITESTCYCLE); + CoreSpan span = mockSpan(InternalSpanTypes.TEST, Collections.emptyMap()); + + dispatcher.addTrace(Collections.singletonList(span)); + dispatcher.flush(); + dispatcher.addTrace(Collections.singletonList(span)); + dispatcher.flush(); + + List filenames = new ArrayList<>(); + for (Path p : listFiles(outputDir)) { + filenames.add(p.getFileName().toString()); + } + Collections.sort(filenames); + + assertEquals(2, filenames.size()); + Pattern expected = Pattern.compile("tests-\\d+-\\d+-\\d+\\.json"); + for (String name : filenames) { + assertTrue(expected.matcher(name).matches(), "filename does not match pattern: " + name); + } + // sequence is the last number — must differ between the two files + assertFalse(filenames.get(0).equals(filenames.get(1))); + } + + @Test + void flushClearsAccumulatorSoSubsequentFlushIsNoOp(@TempDir Path outputDir) throws IOException { + FileBasedPayloadDispatcher dispatcher = + new FileBasedPayloadDispatcher(outputDir, "tests", TrackType.CITESTCYCLE); + CoreSpan span = mockSpan(InternalSpanTypes.TEST, Collections.emptyMap()); + + dispatcher.addTrace(Collections.singletonList(span)); + dispatcher.flush(); + dispatcher.flush(); + + assertEquals(1, listFiles(outputDir).size()); + } + + @Test + void outputDirectoryIsCreatedOnFirstFlush(@TempDir Path tmp) throws IOException { + Path nested = tmp.resolve("nested/does-not-exist"); + assertFalse(Files.exists(nested)); + FileBasedPayloadDispatcher dispatcher = + new FileBasedPayloadDispatcher(nested, "tests", TrackType.CITESTCYCLE); + dispatcher.addTrace( + Collections.singletonList(mockSpan(InternalSpanTypes.TEST, Collections.emptyMap()))); + + dispatcher.flush(); + + assertTrue(Files.isDirectory(nested)); + assertEquals(1, listFiles(nested).size()); + } + + @SuppressWarnings("unchecked") + private static CoreSpan mockSpan(CharSequence type, Map tags) { + CoreSpan span = mock(CoreSpan.class); + when(span.getType()).thenReturn(type); + when(span.getTraceId()).thenReturn(DDTraceId.from(1L)); + when(span.getSpanId()).thenReturn(100L); + when(span.getParentId()).thenReturn(0L); + when(span.getServiceName()).thenReturn("service"); + when(span.getOperationName()).thenReturn("operation"); + when(span.getResourceName()).thenReturn("resource"); + when(span.getStartTime()).thenReturn(1_000_000L); + when(span.getDurationNano()).thenReturn(500_000L); + when(span.getError()).thenReturn(0); + + // getTag is called for known top-level tags; return a value when present, else null + when(span.getTag(any(String.class))).thenAnswer(inv -> tags.get((String) inv.getArgument(0))); + + // processTagsAndBaggage invokes the consumer with a Metadata built from the tags map + Metadata metadata = + new Metadata( + Thread.currentThread().getId(), + null, + TagMap.fromMap(tags), + Collections.emptyMap(), + 0, + false, + false, + null, + null, + 0, + null); + doAnswer( + inv -> { + MetadataConsumer consumer = inv.getArgument(0); + consumer.accept(metadata); + return null; + }) + .when(span) + .processTagsAndBaggage(any(MetadataConsumer.class)); + + return span; + } + + private static List listFiles(Path dir) throws IOException { + List files = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(dir)) { + for (Path p : stream) { + files.add(p); + } + } + return files; + } +} 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 3b7534eafb5..dc241de5b04 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -293,6 +293,8 @@ import static datadog.trace.api.config.CiVisibilityConfig.TEST_FAILED_TEST_REPLAY_ENABLED; import static datadog.trace.api.config.CiVisibilityConfig.TEST_MANAGEMENT_ATTEMPT_TO_FIX_RETRIES; import static datadog.trace.api.config.CiVisibilityConfig.TEST_MANAGEMENT_ENABLED; +import static datadog.trace.api.config.CiVisibilityConfig.TEST_OPTIMIZATION_MANIFEST_FILE; +import static datadog.trace.api.config.CiVisibilityConfig.TEST_OPTIMIZATION_PAYLOADS_IN_FILES; import static datadog.trace.api.config.CiVisibilityConfig.TEST_SESSION_NAME; import static datadog.trace.api.config.CrashTrackingConfig.CRASH_TRACKING_AGENTLESS; import static datadog.trace.api.config.CrashTrackingConfig.CRASH_TRACKING_AGENTLESS_DEFAULT; @@ -1128,6 +1130,8 @@ public static String getHostName() { private final String gitPullRequestBaseBranchSha; private final String gitCommitHeadSha; private final boolean ciVisibilityFailedTestReplayEnabled; + private final String testOptimizationManifestFile; + private final boolean testOptimizationPayloadsInFiles; private final boolean remoteConfigEnabled; private final boolean remoteConfigIntegrityCheckEnabled; @@ -2579,6 +2583,10 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) ciVisibilityFailedTestReplayEnabled = configProvider.getBoolean(TEST_FAILED_TEST_REPLAY_ENABLED, true); + testOptimizationManifestFile = configProvider.getString(TEST_OPTIMIZATION_MANIFEST_FILE); + testOptimizationPayloadsInFiles = + configProvider.getBoolean(TEST_OPTIMIZATION_PAYLOADS_IN_FILES, false); + remoteConfigEnabled = configProvider.getBoolean( REMOTE_CONFIGURATION_ENABLED, DEFAULT_REMOTE_CONFIG_ENABLED, REMOTE_CONFIG_ENABLED); @@ -2947,8 +2955,11 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) // if API key is not provided, check if any products are using agentless mode and require it if (apiKey == null || apiKey.isEmpty()) { - // CI Visibility - if (isCiVisibilityEnabled() && ciVisibilityAgentlessEnabled) { + // CI Visibility (skip validation in manifest/payloads-in-files mode - no network needed) + if (isCiVisibilityEnabled() + && ciVisibilityAgentlessEnabled + && testOptimizationManifestFile == null + && !testOptimizationPayloadsInFiles) { throw new FatalAgentMisconfigurationError( "Attempt to start in CI Visibility in Agentless mode without API key. " + "Please ensure that either an API key is configured, or the tracer is set up to work with the Agent"); @@ -4340,6 +4351,14 @@ public boolean isCiVisibilityFailedTestReplayEnabled() { return ciVisibilityFailedTestReplayEnabled; } + public String getTestOptimizationManifestFile() { + return testOptimizationManifestFile; + } + + public boolean isTestOptimizationPayloadsInFiles() { + return testOptimizationPayloadsInFiles; + } + public String getGitPullRequestBaseBranch() { return gitPullRequestBaseBranch; } diff --git a/internal-api/src/main/java/datadog/trace/api/civisibility/config/BazelMode.java b/internal-api/src/main/java/datadog/trace/api/civisibility/config/BazelMode.java new file mode 100644 index 00000000000..2f2a028b2bb --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/civisibility/config/BazelMode.java @@ -0,0 +1,309 @@ +package datadog.trace.api.civisibility.config; + +import datadog.trace.api.Config; +import datadog.trace.config.inversion.ConfigHelper; +import datadog.trace.util.Strings; +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BazelMode { + + private static final Logger LOGGER = LoggerFactory.getLogger(BazelMode.class); + + private static volatile BazelMode INSTANCE; + + private static final int SUPPORTED_MANIFEST_VERSION = 1; + + private static final String SETTINGS_FILE = "cache/http/settings.json"; + private static final String FLAKY_TESTS_FILE = "cache/http/flaky_tests.json"; + private static final String KNOWN_TESTS_FILE = "cache/http/known_tests.json"; + private static final String TEST_MANAGEMENT_FILE = "cache/http/test_management.json"; + + /* manifestModeEnabled reports whether a supported manifest was found and can be used for config cache */ + private final boolean manifestModeEnabled; + /* manifestPath is the resolved absolute path to the Bazel manifest while in manifest mode */ + @Nullable private final Path manifestPath; + /* manifestDir is the directory containing the resolved manifest and the cached config files */ + @Nullable private final Path manifestDir; + /* payloadFilesEnabled reports whether Bazel payload-in-file mode is enabled */ + private final boolean payloadFilesEnabled; + /* payloadsDir is the root directory containing payload output directories */ + @Nullable private final Path payloadsDir; + + public static BazelMode get() { + if (INSTANCE == null) { + synchronized (BazelMode.class) { + if (INSTANCE == null) { + INSTANCE = new BazelMode(Config.get()); + } + } + } + return INSTANCE; + } + + // visible for testing + BazelMode(Config config) { + String manifestRloc = config.getTestOptimizationManifestFile(); + if (Strings.isNotBlank(manifestRloc)) { + LOGGER.debug("[bazel mode] Resolving manifest path from '{}'", manifestRloc); + manifestPath = resolveRlocation(manifestRloc); + if (manifestPath != null) { + manifestDir = manifestPath.getParent(); + manifestModeEnabled = isManifestCompatible(manifestPath); + LOGGER.info( + "[bazel mode] Manifest file resolved (path: '{}', enabled: {})", + manifestPath, + manifestModeEnabled); + } else { + manifestModeEnabled = false; + manifestDir = null; + LOGGER.warn( + "[bazel mode] Could not resolve manifest file '{}', disabling manifest mode", + manifestRloc); + } + } else { + manifestModeEnabled = false; + manifestPath = null; + manifestDir = null; + } + + payloadFilesEnabled = config.isTestOptimizationPayloadsInFiles(); + // TEST_UNDECLARED_OUTPUTS_DIR is a Bazel-provided env var, not a DD configuration + String undeclaredOutputsDir = ConfigHelper.env("TEST_UNDECLARED_OUTPUTS_DIR"); + if (payloadFilesEnabled) { + if (Strings.isNotBlank(undeclaredOutputsDir)) { + Path resolved = null; + try { + resolved = Paths.get(undeclaredOutputsDir).resolve("payloads"); + LOGGER.info( + "[bazel mode] Payload-in-files mode enabled with payload directory {}", resolved); + } catch (InvalidPathException e) { + LOGGER.warn( + "[bazel mode] Payload-in-files mode enabled, but could not resolve payload directory"); + } + payloadsDir = resolved; + } else { + LOGGER.warn( + "[bazel mode] Payload-in-files mode enabled, but no payload directory was provided"); + payloadsDir = null; + } + } else { + payloadsDir = null; + } + + LOGGER.debug("[bazel mode] Resolved mode {}", this); + } + + @Override + public String toString() { + return "BazelMode{" + + "manifestModeEnabled=" + + manifestModeEnabled + + ", manifestPath=" + + manifestPath + + ", manifestDir=" + + manifestDir + + ", payloadFilesEnabled=" + + payloadFilesEnabled + + ", payloadsDir=" + + payloadsDir + + '}'; + } + + /** Returns {@code true} if either manifest mode or payloads-in-files mode is active. */ + public boolean isEnabled() { + return manifestModeEnabled || payloadFilesEnabled; + } + + public boolean isManifestModeEnabled() { + return manifestModeEnabled; + } + + public boolean isPayloadFilesEnabled() { + return payloadFilesEnabled; + } + + @Nullable + public Path getPayloadsDir() { + return payloadsDir; + } + + @Nullable + public Path getTestPayloadsDir() { + if (payloadsDir == null) { + return null; + } + return payloadsDir.resolve("tests"); + } + + @Nullable + public Path getCoveragePayloadsDir() { + if (payloadsDir == null) { + return null; + } + return payloadsDir.resolve("coverage"); + } + + @Nullable + public Path getTelemetryPayloadsDir() { + if (payloadsDir == null) { + return null; + } + return payloadsDir.resolve("telemetry"); + } + + @Nullable + public Path getSettingsPath() { + return resolveToptFile(SETTINGS_FILE); + } + + @Nullable + public Path getFlakyTestsPath() { + return resolveToptFile(FLAKY_TESTS_FILE); + } + + @Nullable + public Path getKnownTestsPath() { + return resolveToptFile(KNOWN_TESTS_FILE); + } + + @Nullable + public Path getTestManagementPath() { + return resolveToptFile(TEST_MANAGEMENT_FILE); + } + + @Nullable + private Path resolveToptFile(String relativePath) { + if (manifestDir == null) { + return null; + } + Path path = manifestDir.resolve(relativePath); + return Files.exists(path) ? path : null; + } + + private static boolean isManifestCompatible(Path manifestPath) { + try (BufferedReader reader = Files.newBufferedReader(manifestPath)) { + String firstLine = reader.readLine(); + if (firstLine == null) { + LOGGER.warn("[bazel mode] Manifest file is empty: {}", manifestPath); + return false; + } + // manifest.txt first line has the shape `version=` + String trimmed = firstLine.trim(); + int separatorIdx = trimmed.indexOf('='); + if (separatorIdx < 0 || !"version".equals(trimmed.substring(0, separatorIdx).trim())) { + LOGGER.warn("[bazel mode] Could not parse manifest version from line: '{}'", trimmed); + return false; + } + String versionValue = trimmed.substring(separatorIdx + 1).trim(); + try { + int version = Integer.parseInt(versionValue); + if (version == SUPPORTED_MANIFEST_VERSION) { + return true; + } + LOGGER.warn( + "[bazel mode] Unsupported manifest version: {}, supported: {}", + version, + SUPPORTED_MANIFEST_VERSION); + return false; + } catch (NumberFormatException e) { + LOGGER.warn("[bazel mode] Could not parse manifest version from line: '{}'", trimmed); + return false; + } + } catch (IOException e) { + LOGGER.warn("[bazel mode] Error reading manifest file: {}", manifestPath, e); + return false; + } + } + + /** + * Resolves a Bazel runfile rlocation path to an absolute path, checking whether it exists. + * Implements the 4-step algorithm: check direct path, $RUNFILES_DIR, $RUNFILES_MANIFEST_FILE, + * $TEST_SRCDIR. + */ + @Nullable + private static Path resolveRlocation(String rlocation) { + if (Strings.isBlank(rlocation)) { + return null; + } + + try { + Path directPath = Paths.get(rlocation); + if (Files.exists(directPath)) { + LOGGER.debug("[bazel mode] Resolved manifest directly"); + return directPath; + } + + String runfilesDir = ConfigHelper.env("RUNFILES_DIR"); + if (Strings.isNotBlank(runfilesDir)) { + Path candidate = Paths.get(runfilesDir, rlocation); + if (Files.exists(candidate)) { + LOGGER.debug( + "[bazel mode] Manifest resolved via RUNFILES_DIR (dir: {}, candidate: {})", + runfilesDir, + candidate); + return candidate; + } + } + + String manifestFile = ConfigHelper.env("RUNFILES_MANIFEST_FILE"); + if (Strings.isNotBlank(manifestFile)) { + Path resolved = lookupInRunfilesManifest(Paths.get(manifestFile), rlocation); + if (resolved != null) { + LOGGER.debug( + "[bazel mode] Manifest resolved via RUNFILES_MANIFEST_FILE (candidate: {})", + resolved); + return resolved; + } + } + + String testSrcDir = ConfigHelper.env("TEST_SRCDIR"); + if (Strings.isNotBlank(testSrcDir)) { + Path candidate = Paths.get(testSrcDir, rlocation); + if (Files.exists(candidate)) { + LOGGER.debug( + "[bazel mode] Manifest resolved via TEST_SRCDIR (dir: {}, candidate: {})", + testSrcDir, + candidate); + return candidate; + } + } + } catch (InvalidPathException ignored) { + } + + return null; + } + + @Nullable + private static Path lookupInRunfilesManifest(Path manifestFile, String rlocation) { + LOGGER.debug( + "[bazel mode] Reading runfiles manifest {} for rlocation {}", manifestFile, rlocation); + try (BufferedReader reader = Files.newBufferedReader(manifestFile)) { + String line; + while ((line = reader.readLine()) != null) { + int spaceIdx = line.indexOf(' '); + if (spaceIdx > 0 && line.substring(0, spaceIdx).equals(rlocation)) { + return Paths.get(line.substring(spaceIdx + 1)); + } + } + } catch (IOException e) { + LOGGER.debug("[bazel mode] Error reading runfiles manifest: {}", manifestFile, e); + return null; + } + LOGGER.debug( + "[bazel mode] Runfiles manifest {} did not contain rlocation {}", manifestFile, rlocation); + return null; + } + + // visible for testing + static void reset() { + INSTANCE = null; + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/civisibility/config/BazelModeTest.java b/internal-api/src/test/java/datadog/trace/api/civisibility/config/BazelModeTest.java new file mode 100644 index 00000000000..dace3c82b51 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/civisibility/config/BazelModeTest.java @@ -0,0 +1,185 @@ +package datadog.trace.api.civisibility.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import datadog.environment.EnvironmentVariables; +import datadog.trace.api.Config; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class BazelModeTest { + + private EnvironmentVariables.EnvironmentVariablesProvider originalProvider; + private TestEnvironmentVariables envProvider; + + @BeforeEach + void setUp() { + originalProvider = EnvironmentVariables.provider; + envProvider = new TestEnvironmentVariables(); + EnvironmentVariables.provider = envProvider; + BazelMode.reset(); + } + + @AfterEach + void tearDown() { + EnvironmentVariables.provider = originalProvider; + BazelMode.reset(); + } + + @Test + void disabledWhenNoConfigProvided() { + BazelMode mode = new BazelMode(configWith(null, false)); + + assertFalse(mode.isEnabled()); + assertFalse(mode.isManifestModeEnabled()); + assertFalse(mode.isPayloadFilesEnabled()); + assertNull(mode.getPayloadsDir()); + assertNull(mode.getTestPayloadsDir()); + assertNull(mode.getCoveragePayloadsDir()); + assertNull(mode.getTelemetryPayloadsDir()); + assertNull(mode.getSettingsPath()); + } + + @Test + void manifestModeEnabledWithCompatibleManifest(@TempDir Path tmp) throws IOException { + Path manifest = writeManifest(tmp, "1"); + + BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); + + assertTrue(mode.isEnabled()); + assertTrue(mode.isManifestModeEnabled()); + } + + @Test + void manifestModeDisabledForUnsupportedVersion(@TempDir Path tmp) throws IOException { + Path manifest = writeManifest(tmp, "999"); + + BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); + + assertFalse(mode.isManifestModeEnabled()); + } + + @Test + void manifestModeDisabledForUnparseableVersion(@TempDir Path tmp) throws IOException { + Path manifest = writeManifest(tmp, "not-a-number"); + + BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); + + assertFalse(mode.isManifestModeEnabled()); + } + + @Test + void manifestResolvedViaRunfilesDir(@TempDir Path tmp) throws IOException { + Path runfiles = Files.createDirectories(tmp.resolve("runfiles")); + Path manifest = writeManifest(runfiles.resolve(".testoptimization"), "1"); + envProvider.set("RUNFILES_DIR", runfiles.toString()); + String rlocation = runfiles.relativize(manifest).toString(); + + BazelMode mode = new BazelMode(configWith(rlocation, false)); + + assertTrue(mode.isManifestModeEnabled()); + } + + @Test + void manifestResolvedViaRunfilesManifestFile(@TempDir Path tmp) throws IOException { + Path actualManifest = writeManifest(tmp.resolve(".testoptimization"), "1"); + Path runfilesManifest = tmp.resolve("runfiles.manifest"); + Files.write( + runfilesManifest, + ("myproj/.testoptimization/manifest.txt " + actualManifest + "\n") + .getBytes(StandardCharsets.UTF_8)); + envProvider.set("RUNFILES_MANIFEST_FILE", runfilesManifest.toString()); + + BazelMode mode = new BazelMode(configWith("myproj/.testoptimization/manifest.txt", false)); + + assertTrue(mode.isManifestModeEnabled()); + } + + @Test + void cachePathsResolveOnlyWhenFileExists(@TempDir Path tmp) throws IOException { + Path manifestDir = Files.createDirectories(tmp.resolve(".testoptimization")); + Path manifest = writeManifest(manifestDir, "1"); + Path httpDir = Files.createDirectories(manifestDir.resolve("cache/http")); + Files.write(httpDir.resolve("settings.json"), "{}".getBytes(StandardCharsets.UTF_8)); + + BazelMode mode = new BazelMode(configWith(manifest.toString(), false)); + + assertNotNull(mode.getSettingsPath()); + assertEquals(httpDir.resolve("settings.json"), mode.getSettingsPath()); + // files that do not exist return null + assertNull(mode.getKnownTestsPath()); + assertNull(mode.getTestManagementPath()); + assertNull(mode.getFlakyTestsPath()); + } + + @Test + void payloadDirsDerivedFromUndeclaredOutputsDir(@TempDir Path tmp) { + envProvider.set("TEST_UNDECLARED_OUTPUTS_DIR", tmp.toString()); + + BazelMode mode = new BazelMode(configWith(null, true)); + + assertTrue(mode.isEnabled()); + assertTrue(mode.isPayloadFilesEnabled()); + assertEquals(tmp.resolve("payloads"), mode.getPayloadsDir()); + assertEquals(tmp.resolve("payloads/tests"), mode.getTestPayloadsDir()); + assertEquals(tmp.resolve("payloads/coverage"), mode.getCoveragePayloadsDir()); + assertEquals(tmp.resolve("payloads/telemetry"), mode.getTelemetryPayloadsDir()); + } + + @Test + void payloadDirsNullWhenUndeclaredOutputsDirMissing() { + BazelMode mode = new BazelMode(configWith(null, true)); + + assertTrue(mode.isPayloadFilesEnabled()); + assertNull(mode.getPayloadsDir()); + assertNull(mode.getTestPayloadsDir()); + assertNull(mode.getCoveragePayloadsDir()); + assertNull(mode.getTelemetryPayloadsDir()); + } + + private static Config configWith(String manifestRloc, boolean payloadsInFiles) { + Config config = mock(Config.class); + when(config.getTestOptimizationManifestFile()).thenReturn(manifestRloc); + when(config.isTestOptimizationPayloadsInFiles()).thenReturn(payloadsInFiles); + return config; + } + + private static Path writeManifest(Path dir, String version) throws IOException { + Files.createDirectories(dir); + Path manifest = dir.resolve("manifest.txt"); + Files.write(manifest, ("version=" + version).getBytes(StandardCharsets.UTF_8)); + return manifest; + } + + static class TestEnvironmentVariables extends EnvironmentVariables.EnvironmentVariablesProvider { + private final Map env = new HashMap<>(); + + void set(String name, String value) { + env.put(name, value); + } + + @Override + public String get(String name) { + return env.get(name); + } + + @Override + public Map getAll() { + return env; + } + } +} diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index ac7935039e3..75a389428d1 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -3913,6 +3913,22 @@ "aliases": [] } ], + "DD_TEST_OPTIMIZATION_MANIFEST_FILE": [ + { + "version": "A", + "type": "string", + "default": null, + "aliases": [] + } + ], + "DD_TEST_OPTIMIZATION_PAYLOADS_IN_FILES": [ + { + "version": "A", + "type": "boolean", + "default": "false", + "aliases": [] + } + ], "DD_TEST_SESSION_NAME": [ { "version": "A", diff --git a/telemetry/src/main/java/datadog/telemetry/FileBasedTelemetryClient.java b/telemetry/src/main/java/datadog/telemetry/FileBasedTelemetryClient.java new file mode 100644 index 00000000000..f07232884aa --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/FileBasedTelemetryClient.java @@ -0,0 +1,87 @@ +package datadog.telemetry; + +import datadog.trace.util.PidHelper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.atomic.AtomicLong; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okio.Buffer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link TelemetryClient} that writes telemetry payloads to JSON files instead of posting them + * over HTTP. Used in Bazel's hermetic sandbox where network access is forbidden. + * + *

Each request produces a single JSON file named {@code telemetry-{seq:020d}-{pid}.json}. The + * zero-padded sequence prefix preserves ordering for deterministic replay. + */ +public class FileBasedTelemetryClient extends TelemetryClient { + + private static final Logger log = LoggerFactory.getLogger(FileBasedTelemetryClient.class); + + private static final String DD_TELEMETRY_REQUEST_TYPE = "DD-Telemetry-Request-Type"; + private static final HttpUrl PLACEHOLDER_URL = + HttpUrl.get("http://localhost/bazel-file-telemetry"); + + private final Path outputDir; + private final AtomicLong sequence = new AtomicLong(0); + + public FileBasedTelemetryClient(Path outputDir) { + super(null, null, PLACEHOLDER_URL, null); + this.outputDir = outputDir; + } + + @Override + public Result sendHttpRequest(Request.Builder httpRequestBuilder) { + Request request = httpRequestBuilder.url(PLACEHOLDER_URL).build(); + String requestType = request.header(DD_TELEMETRY_REQUEST_TYPE); + + try { + ensureOutputDir(); + byte[] bytes = readBody(request); + writeFileAtomically(bytes); + if (log.isDebugEnabled()) { + log.debug( + "[bazel mode] Wrote telemetry payload {} ({} bytes) to {}", + requestType, + bytes.length, + outputDir); + } + return Result.SUCCESS; + } catch (IOException e) { + log.error( + "[bazel mode] Failed to write telemetry payload {} to {}", requestType, outputDir, e); + return Result.FAILURE; + } + } + + private void ensureOutputDir() throws IOException { + if (!Files.exists(outputDir)) { + Files.createDirectories(outputDir); + } + } + + private static byte[] readBody(Request request) throws IOException { + if (request.body() == null) { + return new byte[0]; + } + Buffer buffer = new Buffer(); + request.body().writeTo(buffer); + return buffer.readByteArray(); + } + + private void writeFileAtomically(byte[] data) throws IOException { + long seq = sequence.getAndIncrement(); + String pid = PidHelper.getPid(); + String filename = String.format("telemetry-%020d-%s.json", seq, pid); + Path target = outputDir.resolve(filename); + Path tmp = outputDir.resolve(filename + ".tmp"); + + Files.write(tmp, data); + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE); + } +} diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java b/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java index 1636f865def..472cc697ddc 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java @@ -10,9 +10,9 @@ public class TelemetryRouter { private static final Logger log = LoggerFactory.getLogger(TelemetryRouter.class); - private final DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery; + @Nullable private final DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery; private final TelemetryClient agentClient; - private final TelemetryClient intakeClient; + @Nullable private final TelemetryClient intakeClient; private final boolean useIntakeClientByDefault; private TelemetryClient currentClient; private boolean errorReported; @@ -29,7 +29,22 @@ public TelemetryRouter( this.currentClient = useIntakeClientByDefault ? intakeClient : agentClient; } + /** + * Single-client constructor used for Bazel file-based telemetry. Feature discovery and + * client-switching logic are skipped. + */ + public TelemetryRouter(TelemetryClient singleClient) { + this.ddAgentFeaturesDiscovery = null; + this.agentClient = singleClient; + this.intakeClient = null; + this.useIntakeClientByDefault = false; + this.currentClient = singleClient; + } + public TelemetryClient.Result sendRequest(TelemetryRequest request) { + if (ddAgentFeaturesDiscovery == null) { + return currentClient.sendHttpRequest(request.httpRequest()); + } ddAgentFeaturesDiscovery.discoverIfOutdated(); boolean agentSupportsTelemetryProxy = ddAgentFeaturesDiscovery.supportsTelemetryProxy(); diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryService.java b/telemetry/src/main/java/datadog/telemetry/TelemetryService.java index 87764f483ea..a5490e0649c 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryService.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryService.java @@ -73,6 +73,11 @@ public static TelemetryService build( return new TelemetryService(telemetryRouter, DEFAULT_MESSAGE_BYTES_SOFT_LIMIT, debug); } + public static TelemetryService buildFileBased(FileBasedTelemetryClient client, boolean debug) { + TelemetryRouter telemetryRouter = new TelemetryRouter(client); + return new TelemetryService(telemetryRouter, DEFAULT_MESSAGE_BYTES_SOFT_LIMIT, debug); + } + // For testing purposes TelemetryService( final TelemetryRouter telemetryRouter, diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java b/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java index 4cb17852652..7603e456a98 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java @@ -20,10 +20,12 @@ import datadog.telemetry.rum.RumPeriodicAction; import datadog.trace.api.Config; import datadog.trace.api.InstrumenterConfig; +import datadog.trace.api.civisibility.config.BazelMode; import datadog.trace.api.iast.telemetry.Verbosity; import datadog.trace.api.rum.RumInjector; import datadog.trace.util.AgentThreadFactory; import java.lang.instrument.Instrumentation; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import org.slf4j.Logger; @@ -94,9 +96,29 @@ static Thread createTelemetryRunnable( public static void startTelemetry( Instrumentation instrumentation, SharedCommunicationObjects sco) { Config config = Config.get(); + boolean debug = config.isTelemetryDebugRequestsEnabled(); + boolean telemetryMetricsEnabled = config.isTelemetryMetricsEnabled(); + + // CI Visibility bazel support writes telemetry to files instead of the network + if (config.isCiVisibilityEnabled() && BazelMode.get().isPayloadFilesEnabled()) { + Path telemetryDir = BazelMode.get().getTelemetryPayloadsDir(); + if (telemetryDir == null) { + log.warn( + "[bazel mode] Payload-in-files mode enabled but telemetry directory not resolved, disabling telemetry"); + return; + } + log.info("[bazel mode] Writing telemetry payloads to {}", telemetryDir); + DependencyService dependencyService = createDependencyService(instrumentation); + TelemetryService telemetryService = + TelemetryService.buildFileBased(new FileBasedTelemetryClient(telemetryDir), debug); + TELEMETRY_THREAD = + createTelemetryRunnable(telemetryService, dependencyService, telemetryMetricsEnabled); + TELEMETRY_THREAD.start(); + return; + } + sco.createRemaining(config); DependencyService dependencyService = createDependencyService(instrumentation); - boolean debug = config.isTelemetryDebugRequestsEnabled(); DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery = sco.featuresDiscovery(config); HttpRetryPolicy.Factory httpRetryPolicy = @@ -114,7 +136,6 @@ public static void startTelemetry( TelemetryService.build( ddAgentFeaturesDiscovery, agentClient, intakeClient, useIntakeClientByDefault, debug); - boolean telemetryMetricsEnabled = config.isTelemetryMetricsEnabled(); TELEMETRY_THREAD = createTelemetryRunnable(telemetryService, dependencyService, telemetryMetricsEnabled); TELEMETRY_THREAD.start(); diff --git a/telemetry/src/test/groovy/datadog/telemetry/TelemetryRouterSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/TelemetryRouterSpecification.groovy index fe3687ba703..5fa71ea4b23 100644 --- a/telemetry/src/test/groovy/datadog/telemetry/TelemetryRouterSpecification.groovy +++ b/telemetry/src/test/groovy/datadog/telemetry/TelemetryRouterSpecification.groovy @@ -370,6 +370,42 @@ class TelemetryRouterSpecification extends Specification { 500 | _ } + def 'single-client constructor skips feature discovery and delegates to the given client'() { + setup: + def singleClient = Mock(TelemetryClient) + def router = new TelemetryRouter(singleClient) + + when: + def result = router.sendRequest(dummyRequest()) + + then: + result == TelemetryClient.Result.SUCCESS + 1 * singleClient.sendHttpRequest(_) >> TelemetryClient.Result.SUCCESS + 0 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 0 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() + } + + def 'single-client constructor does not switch clients on failure'() { + setup: + def singleClient = Mock(TelemetryClient) + def router = new TelemetryRouter(singleClient) + + when: 'first request fails' + def firstResult = router.sendRequest(dummyRequest()) + + then: + firstResult == TelemetryClient.Result.FAILURE + 1 * singleClient.sendHttpRequest(_) >> TelemetryClient.Result.FAILURE + + when: 'second request goes to the same client' + def secondResult = router.sendRequest(dummyRequest()) + + then: + secondResult == TelemetryClient.Result.SUCCESS + 1 * singleClient.sendHttpRequest(_) >> TelemetryClient.Result.SUCCESS + 0 * ddAgentFeaturesDiscovery._ + } + def 'switch back to Agent if it starts supporting telemetry'() { Request request diff --git a/telemetry/src/test/java/datadog/telemetry/FileBasedTelemetryClientTest.java b/telemetry/src/test/java/datadog/telemetry/FileBasedTelemetryClientTest.java new file mode 100644 index 00000000000..e5263a95c45 --- /dev/null +++ b/telemetry/src/test/java/datadog/telemetry/FileBasedTelemetryClientTest.java @@ -0,0 +1,142 @@ +package datadog.telemetry; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.util.PidHelper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileBasedTelemetryClientTest { + + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + @Test + void writesRequestBodyToFile(@TempDir Path tmp) throws IOException { + Path outputDir = tmp.resolve("payloads/telemetry"); + FileBasedTelemetryClient client = new FileBasedTelemetryClient(outputDir); + byte[] body = "{\"request_type\":\"app-started\"}".getBytes(StandardCharsets.UTF_8); + + TelemetryClient.Result result = client.sendHttpRequest(requestBuilder("app-started", body)); + + assertEquals(TelemetryClient.Result.SUCCESS, result); + List files = listFiles(outputDir); + assertEquals(1, files.size()); + assertArrayEquals(body, Files.readAllBytes(files.get(0))); + } + + @Test + void createsOutputDirectoryIfMissing(@TempDir Path tmp) { + Path outputDir = tmp.resolve("does/not/exist/yet"); + assertFalse(Files.exists(outputDir)); + + FileBasedTelemetryClient client = new FileBasedTelemetryClient(outputDir); + TelemetryClient.Result result = + client.sendHttpRequest( + requestBuilder("app-heartbeat", "{}".getBytes(StandardCharsets.UTF_8))); + + assertEquals(TelemetryClient.Result.SUCCESS, result); + assertTrue(Files.isDirectory(outputDir)); + } + + @Test + void filenameFollowsTelemetryConvention(@TempDir Path tmp) throws IOException { + FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp); + + client.sendHttpRequest(requestBuilder("app-started", "{}".getBytes(StandardCharsets.UTF_8))); + + List files = listFiles(tmp); + assertEquals(1, files.size()); + String expected = String.format("telemetry-%020d-%s.json", 0L, PidHelper.getPid()); + assertEquals(expected, files.get(0).getFileName().toString()); + } + + @Test + void sequenceIncrementsPerRequest(@TempDir Path tmp) throws IOException { + FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp); + + for (int i = 0; i < 3; i++) { + client.sendHttpRequest( + requestBuilder("app-heartbeat", "{}".getBytes(StandardCharsets.UTF_8))); + } + + List files = listFiles(tmp); + assertEquals(3, files.size()); + String pid = PidHelper.getPid(); + for (int i = 0; i < files.size(); i++) { + // files are unsorted from DirectoryStream — build expected set, check contains + String expected = String.format("telemetry-%020d-%s.json", (long) i, pid); + assertTrue( + files.stream().anyMatch(p -> p.getFileName().toString().equals(expected)), + "Missing file with sequence " + i + ": " + expected); + } + } + + @Test + void handlesNullRequestBody(@TempDir Path tmp) throws IOException { + FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp); + Request.Builder builder = + new Request.Builder().addHeader("DD-Telemetry-Request-Type", "app-closing").get(); + + TelemetryClient.Result result = client.sendHttpRequest(builder); + + assertEquals(TelemetryClient.Result.SUCCESS, result); + List files = listFiles(tmp); + assertEquals(1, files.size()); + assertEquals(0, Files.size(files.get(0))); + } + + @Test + void returnsFailureWhenOutputDirIsNotWritable(@TempDir Path tmp) throws IOException { + // Create a regular file at the expected output-dir path so createDirectories fails. + Path collision = tmp.resolve("not-a-dir"); + Files.createFile(collision); + FileBasedTelemetryClient client = new FileBasedTelemetryClient(collision.resolve("sub")); + + TelemetryClient.Result result = + client.sendHttpRequest( + requestBuilder("app-heartbeat", "{}".getBytes(StandardCharsets.UTF_8))); + + assertEquals(TelemetryClient.Result.FAILURE, result); + } + + @Test + void leavesNoLingeringTempFiles(@TempDir Path tmp) throws IOException { + FileBasedTelemetryClient client = new FileBasedTelemetryClient(tmp); + + client.sendHttpRequest(requestBuilder("app-started", "{}".getBytes(StandardCharsets.UTF_8))); + + List files = listFiles(tmp); + assertFalse( + files.stream().anyMatch(p -> p.getFileName().toString().endsWith(".tmp")), + "Found leftover .tmp file: " + files); + } + + private static Request.Builder requestBuilder(String requestType, byte[] body) { + return new Request.Builder() + .addHeader("DD-Telemetry-Request-Type", requestType) + .post(RequestBody.create(JSON, body)); + } + + private static List listFiles(Path dir) throws IOException { + List files = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(dir)) { + for (Path p : stream) { + files.add(p); + } + } + return files; + } +}