diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityCoverageServices.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityCoverageServices.java index c83beb14283..205dba8ae72 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityCoverageServices.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/CiVisibilityCoverageServices.java @@ -37,7 +37,10 @@ static class Parent { CoverageReportUploader coverageReportUploader = executionSettings.isCodeCoverageReportUploadEnabled() ? new CoverageReportUploader( - services.ciIntake, repoServices.ciTags, services.metricCollector) + services.ciIntake, + repoServices.ciTags, + services.config.getCodeCoverageFlags(), + services.metricCollector) : null; coverageProcessorFactory = diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/report/CoverageReportUploader.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/report/CoverageReportUploader.java index 0d0ffef37c9..7777a972a2a 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/report/CoverageReportUploader.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/report/CoverageReportUploader.java @@ -16,7 +16,10 @@ import java.io.InputStream; import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.zip.GZIPOutputStream; import okhttp3.MediaType; @@ -28,26 +31,32 @@ public class CoverageReportUploader { private final BackendApi backendApi; private final Map ciTags; + private final List flags; private final CiVisibilityMetricCollector metricCollector; - private final JsonAdapter> eventAdapter; + private final JsonAdapter> eventAdapter; public CoverageReportUploader( BackendApi backendApi, Map ciTags, + List flags, CiVisibilityMetricCollector metricCollector) { this.backendApi = backendApi; this.ciTags = ciTags; + this.flags = Collections.unmodifiableList(new ArrayList<>(flags)); this.metricCollector = metricCollector; Moshi moshi = new Moshi.Builder().build(); - Type type = Types.newParameterizedType(Map.class, String.class, String.class); + Type type = Types.newParameterizedType(Map.class, String.class, Object.class); eventAdapter = moshi.adapter(type); } public void upload(String format, InputStream reportStream) throws IOException { - Map event = new HashMap<>(ciTags); + Map event = new HashMap<>(ciTags); event.put("format", format); event.put("type", "coverage_report"); + if (!flags.isEmpty()) { + event.put("report.flags", flags); + } String eventJson = eventAdapter.toJson(event); RequestBody eventBody = jsonRequestBodyOf(eventJson.getBytes(StandardCharsets.UTF_8)); diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/coverage/report/CoverageReportUploaderTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/coverage/report/CoverageReportUploaderTest.groovy deleted file mode 100644 index d1322d91c3b..00000000000 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/coverage/report/CoverageReportUploaderTest.groovy +++ /dev/null @@ -1,96 +0,0 @@ -package datadog.trace.civisibility.coverage.report - -import com.fasterxml.jackson.databind.ObjectMapper -import datadog.communication.BackendApi -import datadog.communication.IntakeApi -import datadog.communication.http.HttpRetryPolicy -import datadog.communication.http.OkHttpUtils -import datadog.trace.api.civisibility.telemetry.CiVisibilityMetricCollector -import datadog.trace.api.intake.Intake -import datadog.trace.test.util.MultipartRequestParser -import okhttp3.HttpUrl -import okhttp3.OkHttpClient -import spock.lang.AutoCleanup -import spock.lang.Shared -import spock.lang.Specification - -import java.util.zip.GZIPInputStream - -import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer - -class CoverageReportUploaderTest extends Specification { - - private static final int REQUEST_TIMEOUT_MILLIS = 15_000 - - private static final ObjectMapper JSON_MAPPER = new ObjectMapper() - - private static final String COVERAGE_REPORT_BODY = "report-body" - private static final String JACOCO_FORMAT = "jacoco" - private static final String CI_TAG_KEY = "ci-tag-key" - private static final String CI_TAG_VALUE = "ci-tag-value" - - @AutoCleanup - @Shared - def server = httpServer { - handlers { - prefix('/api/v2/cicovreprt') { - def parsed = MultipartRequestParser.parseRequest(request.body, request.headers.get("Content-Type")) - - def event = JSON_MAPPER.readValue(parsed["event"].get(0).get(), Map) - if (event.size() != 3 || event["format"] != JACOCO_FORMAT || event["type"] != "coverage_report" || event[CI_TAG_KEY] != CI_TAG_VALUE) { - response.status(400).send("Incorrect event $event") - return - } - - def coverage = new String(gunzip(parsed["coverage"].get(0).get())) - if (coverage != COVERAGE_REPORT_BODY) { - response.status(400).send("Incorrect coverage $coverage") - return - } - - response.status(200).send() - } - } - } - - static byte[] gunzip(byte[] compressed) throws IOException { - try (ByteArrayInputStream bais = new ByteArrayInputStream(compressed) - GZIPInputStream gis = new GZIPInputStream(bais) - ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - - byte[] buffer = new byte[8192] - int len - while ((len = gis.read(buffer)) != -1) { - baos.write(buffer, 0, len) - } - return baos.toByteArray() - } - } - - def "test upload coverage report"() { - setup: - def backendApi = givenIntakeApi() - def metricCollector = Stub(CiVisibilityMetricCollector) - def uploader = new CoverageReportUploader(backendApi, [(CI_TAG_KEY):CI_TAG_VALUE], metricCollector) - def report = new ByteArrayInputStream(COVERAGE_REPORT_BODY.getBytes()) - - expect: - uploader.upload(JACOCO_FORMAT, report) - } - - private BackendApi givenIntakeApi() { - HttpUrl intakeUrl = HttpUrl.get(String.format("%s/api/%s/", server.address.toString(), Intake.CI_INTAKE.version)) - - String apiKey = "api-key" - String traceId = "a-trace-id" - - HttpRetryPolicy retryPolicy = Stub(HttpRetryPolicy) - retryPolicy.shouldRetry(_) >> false - - HttpRetryPolicy.Factory retryPolicyFactory = Stub(HttpRetryPolicy.Factory) - retryPolicyFactory.create() >> retryPolicy - - OkHttpClient client = OkHttpUtils.buildHttpClient(intakeUrl, REQUEST_TIMEOUT_MILLIS) - return new IntakeApi(intakeUrl, apiKey, traceId, retryPolicyFactory, client, false) - } -} diff --git a/dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/coverage/report/CoverageReportUploaderTest.java b/dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/coverage/report/CoverageReportUploaderTest.java new file mode 100644 index 00000000000..0a2c82c18dc --- /dev/null +++ b/dd-java-agent/agent-ci-visibility/src/test/java/datadog/trace/civisibility/coverage/report/CoverageReportUploaderTest.java @@ -0,0 +1,131 @@ +package datadog.trace.civisibility.coverage.report; + +import static datadog.trace.agent.test.server.http.JavaTestHttpServer.httpServer; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import datadog.communication.BackendApi; +import datadog.communication.IntakeApi; +import datadog.communication.http.HttpRetryPolicy; +import datadog.communication.http.OkHttpUtils; +import datadog.trace.agent.test.server.http.JavaTestHttpServer; +import datadog.trace.api.civisibility.telemetry.NoOpMetricCollector; +import datadog.trace.api.intake.Intake; +import datadog.trace.test.util.MultipartRequestParser; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import org.apache.commons.fileupload.FileItem; +import org.junit.jupiter.api.Test; + +class CoverageReportUploaderTest { + + private static final int REQUEST_TIMEOUT_MILLIS = 15_000; + + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + private static final TypeReference> EVENT_TYPE = + new TypeReference>() {}; + + private static final String COVERAGE_REPORT_BODY = "report-body"; + private static final String JACOCO_FORMAT = "jacoco"; + private static final String CI_TAG_KEY = "ci-tag-key"; + private static final String CI_TAG_VALUE = "ci-tag-value"; + + @Test + void uploadsCoverageReportWithoutFlags() throws IOException { + CapturedRequest request = uploadCoverageReport(Collections.emptyList()); + + assertEquals(3, request.event.size()); + assertEquals(JACOCO_FORMAT, request.event.get("format")); + assertEquals("coverage_report", request.event.get("type")); + assertEquals(CI_TAG_VALUE, request.event.get(CI_TAG_KEY)); + assertFalse(request.event.containsKey("report.flags")); + assertEquals(COVERAGE_REPORT_BODY, new String(request.coverage, StandardCharsets.UTF_8)); + } + + @Test + void uploadsCoverageReportWithOrderedDuplicateFlags() throws IOException { + List flags = Arrays.asList("type:unit-tests", "jvm-21", "jvm-21"); + + CapturedRequest request = uploadCoverageReport(flags); + + assertEquals(4, request.event.size()); + assertEquals(JACOCO_FORMAT, request.event.get("format")); + assertEquals("coverage_report", request.event.get("type")); + assertEquals(CI_TAG_VALUE, request.event.get(CI_TAG_KEY)); + assertEquals(flags, request.event.get("report.flags")); + assertEquals(COVERAGE_REPORT_BODY, new String(request.coverage, StandardCharsets.UTF_8)); + } + + private static CapturedRequest uploadCoverageReport(List flags) throws IOException { + CapturedRequest capturedRequest = new CapturedRequest(); + try (JavaTestHttpServer server = + httpServer( + s -> + s.handlers( + h -> + h.prefix( + "/api/v2/cicovreprt", + api -> { + Map> multipart = + MultipartRequestParser.parseRequest( + api.getRequest().getBody(), + api.getRequest().getHeader("Content-Type")); + capturedRequest.event = + JSON_MAPPER.readValue( + multipart.get("event").get(0).get(), EVENT_TYPE); + capturedRequest.coverage = + gunzip(multipart.get("coverage").get(0).get()); + api.getResponse().status(200).send(); + })))) { + BackendApi backendApi = givenIntakeApi(server.getAddress()); + CoverageReportUploader uploader = + new CoverageReportUploader( + backendApi, + Collections.singletonMap(CI_TAG_KEY, CI_TAG_VALUE), + flags, + NoOpMetricCollector.INSTANCE); + + uploader.upload( + JACOCO_FORMAT, + new ByteArrayInputStream(COVERAGE_REPORT_BODY.getBytes(StandardCharsets.UTF_8))); + } + return capturedRequest; + } + + private static byte[] gunzip(byte[] compressed) throws IOException { + try (ByteArrayInputStream input = new ByteArrayInputStream(compressed); + GZIPInputStream gzip = new GZIPInputStream(input); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + for (int readCount; (readCount = gzip.read(buffer)) != -1; ) { + output.write(buffer, 0, readCount); + } + return output.toByteArray(); + } + } + + private static BackendApi givenIntakeApi(URI address) { + HttpUrl intakeUrl = + HttpUrl.get(String.format("%s/api/%s/", address, Intake.CI_INTAKE.getVersion())); + HttpRetryPolicy.Factory retryPolicyFactory = new HttpRetryPolicy.Factory(5, 100, 2.0); + OkHttpClient client = OkHttpUtils.buildHttpClient(intakeUrl, REQUEST_TIMEOUT_MILLIS); + return new IntakeApi(intakeUrl, "api-key", "a-trace-id", retryPolicyFactory, client, false); + } + + private static final class CapturedRequest { + private Map event; + private byte[] coverage; + } +} diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/CiVisibilityConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/CiVisibilityConfig.java index 05a1075f57d..7aa7be07380 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 @@ -90,6 +90,7 @@ public final class CiVisibilityConfig { public static final String GIT_COMMIT_HEAD_SHA = "git.commit.head.sha"; /* COVERAGE SETTINGS */ + public static final String CODE_COVERAGE_FLAGS = "code.coverage.flags"; public static final String CIVISIBILITY_CODE_COVERAGE_ENABLED = "civisibility.code.coverage.enabled"; public static final String CIVISIBILITY_CODE_COVERAGE_LINES_ENABLED = 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 c74f276cff7..bc7b0b904af 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -300,6 +300,7 @@ import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_TEST_SKIPPING_ENABLED; import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_TOTAL_FLAKY_RETRY_COUNT; import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_TRACE_SANITATION_ENABLED; +import static datadog.trace.api.config.CiVisibilityConfig.CODE_COVERAGE_FLAGS; import static datadog.trace.api.config.CiVisibilityConfig.GIT_COMMIT_HEAD_SHA; import static datadog.trace.api.config.CiVisibilityConfig.GIT_PULL_REQUEST_BASE_BRANCH; import static datadog.trace.api.config.CiVisibilityConfig.GIT_PULL_REQUEST_BASE_BRANCH_SHA; @@ -822,6 +823,7 @@ public class Config { private static final Logger log = LoggerFactory.getLogger(Config.class); + private static final int MAX_CODE_COVERAGE_FLAGS = 32; private static final Pattern COLON = Pattern.compile(":"); @@ -1148,6 +1150,7 @@ public static String getHostName() { private final boolean ciVisibilityCodeCoverageEnabled; private final Boolean ciVisibilityCoverageLinesEnabled; private final String ciVisibilityCodeCoverageReportDumpDir; + private final List codeCoverageFlags; private final String ciVisibilityCompilerPluginVersion; private final String ciVisibilityJacocoPluginVersion; private final boolean ciVisibilityJacocoPluginVersionProvided; @@ -2700,6 +2703,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) configProvider.getBoolean(CIVISIBILITY_CODE_COVERAGE_LINES_ENABLED); ciVisibilityCodeCoverageReportDumpDir = configProvider.getString(CIVISIBILITY_CODE_COVERAGE_REPORT_DUMP_DIR); + codeCoverageFlags = parseCodeCoverageFlags(configProvider.getList(CODE_COVERAGE_FLAGS)); ciVisibilityCompilerPluginVersion = configProvider.getString( CIVISIBILITY_COMPILER_PLUGIN_VERSION, DEFAULT_CIVISIBILITY_COMPILER_PLUGIN_VERSION); @@ -4399,6 +4403,10 @@ public String getCiVisibilityCodeCoverageReportDumpDir() { return ciVisibilityCodeCoverageReportDumpDir; } + public List getCodeCoverageFlags() { + return codeCoverageFlags; + } + public String getCiVisibilityCompilerPluginVersion() { return ciVisibilityCompilerPluginVersion; } @@ -6081,6 +6089,29 @@ private static Set parseStringIntoSetOfNonEmptyStrings( return Collections.unmodifiableSet(result); } + private static List parseCodeCoverageFlags(List configuredFlags) { + if (configuredFlags.isEmpty()) { + return Collections.emptyList(); + } + + List flags = new ArrayList<>(configuredFlags.size()); + for (String configuredFlag : configuredFlags) { + if (!configuredFlag.isEmpty()) { + flags.add(configuredFlag); + } + } + + if (flags.size() > MAX_CODE_COVERAGE_FLAGS) { + log.warn( + "Cannot apply {} code coverage report flags: the maximum supported number is {}. The report will be uploaded without flags.", + flags.size(), + MAX_CODE_COVERAGE_FLAGS); + return Collections.emptyList(); + } + + return Collections.unmodifiableList(flags); + } + private static Set convertStringSetToSet( String setting, final Set input, Function mapper) { if (input.isEmpty()) { @@ -6269,6 +6300,8 @@ public String toString() { + experimentalFeaturesEnabled + ", integrationSynapseLegacyOperationName=" + integrationSynapseLegacyOperationName + + ", codeCoverageFlags=" + + codeCoverageFlags + ", writerType='" + writerType + '\'' diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigCodeCoverageFlagsTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigCodeCoverageFlagsTest.java new file mode 100644 index 00000000000..596fa82b693 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/ConfigCodeCoverageFlagsTest.java @@ -0,0 +1,98 @@ +package datadog.trace.api; + +import static datadog.trace.api.config.CiVisibilityConfig.CODE_COVERAGE_FLAGS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import datadog.trace.junit.utils.config.WithConfigExtension; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.LoggerFactory; + +@ExtendWith(WithConfigExtension.class) +class ConfigCodeCoverageFlagsTest { + + @Test + void defaultsToNoFlags() { + assertEquals(Collections.emptyList(), Config.get().getCodeCoverageFlags()); + } + + @Test + void removesWhitespaceAndEmptyFlagsWhilePreservingOrderAndDuplicates() { + Config config = configWithFlags(" type:unit-tests, ,jvm-21,, type:unit-tests "); + + assertEquals( + Arrays.asList("type:unit-tests", "jvm-21", "type:unit-tests"), + config.getCodeCoverageFlags()); + } + + @Test + void readsFlagsFromCanonicalEnvironmentVariable() { + WithConfigExtension.injectEnvConfig("DD_CODE_COVERAGE_FLAGS", "type:unit-tests,jvm-21", false); + + assertEquals(Arrays.asList("type:unit-tests", "jvm-21"), Config.get().getCodeCoverageFlags()); + } + + @Test + void producesNoFlagsForEmptyInput() { + assertEquals(Collections.emptyList(), configWithFlags(" , , ").getCodeCoverageFlags()); + } + + @Test + void acceptsExactlyThirtyTwoFlagsAndReturnsImmutableSnapshot() { + List expectedFlags = flags(32); + Config config = configWithFlags(String.join(",", expectedFlags)); + + assertEquals(expectedFlags, config.getCodeCoverageFlags()); + assertThrows( + UnsupportedOperationException.class, () -> config.getCodeCoverageFlags().add("extra")); + } + + @Test + void omitsAllFlagsWhenMoreThanThirtyTwoAreConfigured() { + Logger logger = (Logger) LoggerFactory.getLogger(Config.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + + try { + Config config = configWithFlags(String.join(",", flags(33))); + + assertEquals(Collections.emptyList(), config.getCodeCoverageFlags()); + List overflowWarnings = new ArrayList<>(); + for (ILoggingEvent event : appender.list) { + if (event.getLevel() == Level.WARN + && event.getFormattedMessage().contains("code coverage report flags")) { + overflowWarnings.add(event); + } + } + assertEquals(1, overflowWarnings.size()); + assertTrue(overflowWarnings.get(0).getFormattedMessage().contains("33")); + assertTrue(overflowWarnings.get(0).getFormattedMessage().contains("32")); + } finally { + logger.detachAppender(appender); + } + } + + private static Config configWithFlags(String flags) { + WithConfigExtension.injectSysConfig(CODE_COVERAGE_FLAGS, flags); + return Config.get(); + } + + private static List flags(int count) { + List flags = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + flags.add("flag-" + i); + } + return flags; + } +} diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 6241dc3e6af..58aa001e32f 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -913,6 +913,14 @@ "aliases": [] } ], + "DD_CODE_COVERAGE_FLAGS": [ + { + "version": "A", + "type": "array", + "default": null, + "aliases": [] + } + ], "DD_CODE_ORIGIN_FOR_SPANS_ENABLED": [ { "version": "B",