diff --git a/dd-java-agent/agent-profiling/build.gradle b/dd-java-agent/agent-profiling/build.gradle index 0504d15f5d1..737ba5c446a 100644 --- a/dd-java-agent/agent-profiling/build.gradle +++ b/dd-java-agent/agent-profiling/build.gradle @@ -10,7 +10,8 @@ excludedClassesCoverage += [ 'com.datadog.profiling.agent.CompositeController.CompositeOngoingRecording', 'com.datadog.profiling.agent.ProfilingAgent', 'com.datadog.profiling.agent.ProfilingAgent.ShutdownHook', - 'com.datadog.profiling.agent.ProfilingAgent.DataDumper' + 'com.datadog.profiling.agent.ProfilingAgent.DataDumper', + 'com.datadog.profiling.agent.ProfilerFlare' ] dependencies { diff --git a/dd-smoke-tests/src/main/groovy/datadog/smoketest/ProcessManager.groovy b/dd-smoke-tests/src/main/groovy/datadog/smoketest/ProcessManager.groovy index 9d7fe392fd7..64effc049ea 100644 --- a/dd-smoke-tests/src/main/groovy/datadog/smoketest/ProcessManager.groovy +++ b/dd-smoke-tests/src/main/groovy/datadog/smoketest/ProcessManager.groovy @@ -60,7 +60,7 @@ abstract class ProcessManager extends Specification { def setup() { testedProcesses.each { - assert it.alive: "Process $it is not availble on test beginning" + assert it.alive: "Process $it is not available on test beginning" } synchronized (outputThreads.testLogMessages) { diff --git a/dd-smoke-tests/tracer-flare/build.gradle b/dd-smoke-tests/tracer-flare/build.gradle new file mode 100644 index 00000000000..136156adc98 --- /dev/null +++ b/dd-smoke-tests/tracer-flare/build.gradle @@ -0,0 +1,18 @@ +apply from: "$rootDir/gradle/java.gradle" +description = 'Tracer Flare Smoke Tests.' + +jar { + manifest { + attributes('Main-Class': 'datadog.smoketest.flare.SimpleApp') + } +} + +dependencies { + testImplementation project(':dd-smoke-tests') +} + +tasks.withType(Test).configureEach { + dependsOn "jar" + + jvmArgs "-Ddatadog.smoketest.tracer-flare.jar.path=${tasks.jar.archiveFile.get()}" +} diff --git a/dd-smoke-tests/tracer-flare/src/main/java/datadog/smoketest/flare/SimpleApp.java b/dd-smoke-tests/tracer-flare/src/main/java/datadog/smoketest/flare/SimpleApp.java new file mode 100644 index 00000000000..d96431ed51a --- /dev/null +++ b/dd-smoke-tests/tracer-flare/src/main/java/datadog/smoketest/flare/SimpleApp.java @@ -0,0 +1,15 @@ +package datadog.smoketest.flare; + +public class SimpleApp { + public static void main(String[] args) throws InterruptedException { + System.out.println("SimpleApp starting - waiting for flare generation"); + + // Keep the app running indefinitely + // The flare will be triggered after 10 seconds (configured in test) + // The test will wait for the flare and then terminate the process + while (true) { + System.out.println("SimpleApp running..."); + Thread.sleep(5000); + } + } +} diff --git a/dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy b/dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy new file mode 100644 index 00000000000..9ecd92795c3 --- /dev/null +++ b/dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy @@ -0,0 +1,276 @@ +package datadog.smoketest + +import spock.lang.Shared + +import java.nio.file.FileSystems +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds +import java.nio.file.WatchEvent +import java.nio.file.WatchKey +import java.nio.file.WatchService +import java.util.concurrent.TimeUnit +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +/** + * This smoke test can be extended with additional configurations of the agent and assertions on the files + * expected to be present in the resulting flare(s). Checking individual file contents should be done a per-reporter + * basis. + * + * For DD Employees - if you update this file alongside changes to an existing reporter or the creation of a new one, + * please also document it in the + * Tracer Flare Wiki + */ +class TracerFlareSmokeTest extends AbstractSmokeTest { + + // Time in seconds after which flare is triggered + private static final int FLARE_TRIGGER_SECONDS = 15 + // Number of processes to run in parallel for testing + private static final int NUMBER_OF_PROCESSES = 2 + + protected int numberOfProcesses() { + NUMBER_OF_PROCESSES + } + + @Shared + final flareDirs = [ + File.createTempDir("flare-test-profiling-enabled-", ""), + File.createTempDir("flare-test-profiling-disabled-", "") + ] + + def cleanupSpec() { + flareDirs.each { dir -> + if (dir.exists()) { + dir.deleteDir() + } + } + } + + @Override + ProcessBuilder createProcessBuilder(int processIndex) { + String jarPath = System.getProperty("datadog.smoketest.tracer-flare.jar.path") + File flareDir = flareDirs[processIndex] + + def command = [javaPath()] + + if (processIndex == 0) { + // Process 0: Profiling enabled (default) + command.addAll(defaultJavaProperties) + } else { + // Process 1: Profiling disabled + def filteredProperties = defaultJavaProperties.findAll { prop -> + !prop.startsWith("-Ddd.profiling.") + } + command.addAll(filteredProperties) + command.add("-Ddd.profiling.enabled=false") + } + + // Configure flare generation + command.addAll([ + "-Ddd.triage.report.trigger=${FLARE_TRIGGER_SECONDS}s", + "-Ddd.triage.report.dir=${flareDir.absolutePath}", + "-Ddd.trace.debug=true", + // Enable debug to get more files + '-jar', + jarPath + ] as String[]) + + new ProcessBuilder(command).tap { + it.directory(new File(buildDirectory)) + } + } + + // Core files that should always be present + private static final CORE_FILES = [ + "flare_info.txt", + "tracer_version.txt", + "initial_config.txt", + "dynamic_config.txt", + "jvm_args.txt", + "classpath.txt", + "library_path.txt", + "threads.txt", + // Should be present with triage=true + // Files from CoreTracer + "tracer_health.txt", + "span_metrics.txt", + // Files from InstrumenterFlare (always registered) + "instrumenter_state.txt", + "instrumenter_metrics.txt", + // Files from DI + "dynamic_instrumentation.txt" + ] as Set + + // Optional files that may or may not be present depending on conditions + private static final OPTIONAL_FILES = [ + "boot_classpath.txt", + // Only if JVM supports it (Java 8) + "tracer.log", + // Only if logging is configured + "tracer_begin.log", + // Alternative log format + "tracer_end.log", + // Alternative log format + "flare_errors.txt", + // Only if there were errors + "pending_traces.txt" // Only if there were traces pending transmission + ] as Set + + // Profiling-related files + private static final PROFILING_FILES = [ + "profiler_config.txt", + // Only if profiling is enabled + "profiling_template_override.jfp" // Only if template override is configured + ] as Set + + // Flare file naming pattern constants + private static final String FLARE_FILE_PREFIX = "dd-java-flare-" + private static final String FLARE_FILE_EXTENSION = ".zip" + + /** + * Checks if a filename matches the expected flare file pattern + */ + private static boolean isFlareFile(String fileName) { + fileName.startsWith(FLARE_FILE_PREFIX) && fileName.endsWith(FLARE_FILE_EXTENSION) + } + + def "tracer generates flare with profiling enabled (default)"() { + when: + // Wait for flare file to be created using filesystem watcher + // The flare is triggered after FLARE_TRIGGER_SECONDS, plus some write time + def flareFile = waitForFlareFile(flareDirs[0]) + def zipContents = extractZipContents(flareFile) + + then: + // Verify core files are present + CORE_FILES.each { file -> + assert file in zipContents : "Missing required core file: ${file}" + } + + // Verify profiling files are present (profiling is enabled in defaultJavaProperties) + assert "profiler_config.txt" in zipContents : "Missing profiler_config.txt when profiling is enabled" + + // Check for unexpected files and fail if found + validateNoUnexpectedFiles(zipContents, CORE_FILES + OPTIONAL_FILES + PROFILING_FILES) + } + + def "tracer generates flare with profiling disabled"() { + when: + // Wait for flare file to be created independently for process 1 + // Each test should be independent and not rely on timing from other tests + def flareFile = waitForFlareFile(flareDirs[1]) + def zipContents = extractZipContents(flareFile) + + then: + // Verify core files are present + CORE_FILES.each { file -> + assert file in zipContents : "Missing required core file: ${file}" + } + + // Verify NO profiling files are present when profiling is disabled + PROFILING_FILES.each { file -> + assert !(file in zipContents) : "Found profiling file '${file}' when profiling is disabled" + } + + // Check for unexpected files and fail if found (profiling files excluded from expected) + validateNoUnexpectedFiles(zipContents, CORE_FILES + OPTIONAL_FILES) + } + + private static void validateNoUnexpectedFiles(Set zipContents, Set expectedFiles) { + def unexpectedFiles = zipContents - expectedFiles + assert !unexpectedFiles : "Found unexpected files in flare: ${unexpectedFiles}" + } + + private static Set extractZipContents(File zipFile) { + def fileNames = [] + + zipFile.withInputStream { stream -> + new ZipInputStream(stream).withCloseable { zis -> + ZipEntry entry + while ((entry = zis.nextEntry) != null) { + if (!entry.directory) { + fileNames << entry.name + } + zis.closeEntry() + } + } + } + + fileNames + } + + /** + * Waits for a flare file to be created in the specified directory using filesystem watching. + * + * @param flareDir The directory to watch for flare files + * @param timeoutSeconds Maximum time to wait for the file + * @return The created flare file + * @throws AssertionError if no flare file is created within the timeout + */ + private static File waitForFlareFile(File flareDir, int timeoutSeconds = FLARE_TRIGGER_SECONDS + 5) { + Path dirPath = flareDir.toPath() + WatchService watchService = FileSystems.getDefault().newWatchService() + + try { + def existingFile = findFlareFileIfExists(flareDir) + if (existingFile) { + return existingFile + } + + dirPath.register(watchService, StandardWatchEventKinds.ENTRY_CREATE) + long deadlineMillis = System.currentTimeMillis() + (timeoutSeconds * 1000) + + while (System.currentTimeMillis() < deadlineMillis) { + long remainingMillis = deadlineMillis - System.currentTimeMillis() + if (remainingMillis <= 0) { + break + } + + WatchKey key = watchService.poll(remainingMillis, TimeUnit.MILLISECONDS) + if (key == null) { + existingFile = findFlareFileIfExists(flareDir) + if (existingFile) { + return existingFile + } + break + } + + for (WatchEvent event : key.pollEvents()) { + WatchEvent pathEvent = (WatchEvent) event + Path fileName = pathEvent.context() + + if (isFlareFile(fileName.toString())) { + return new File(flareDir, fileName.toString()) + } + } + + boolean valid = key.reset() + if (!valid) { + throw new AssertionError("Watch directory ${flareDir} is no longer accessible") + } + } + + existingFile = findFlareFileIfExists(flareDir) + if (existingFile) { + return existingFile + } + + throw new AssertionError("No flare file created in ${flareDir} within ${timeoutSeconds} seconds") + + } finally { + watchService.close() + } + } + + /** + * Attempts to find an existing flare file in the directory. + * Returns null if no flare file exists. + */ + private static File findFlareFileIfExists(File flareDir) { + def flareFiles = flareDir.listFiles({ File dir, String name -> + isFlareFile(name) + } as FilenameFilter) + + return flareFiles?.size() > 0 ? flareFiles.first() : null + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2de3e282faa..cfd3bc5f748 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -215,6 +215,7 @@ include( ":dd-smoke-tests:springboot-tomcat", ":dd-smoke-tests:springboot-tomcat-jsp", ":dd-smoke-tests:springboot-velocity", + ":dd-smoke-tests:tracer-flare", ":dd-smoke-tests:vertx-3.4", ":dd-smoke-tests:vertx-3.9", ":dd-smoke-tests:vertx-3.9-resteasy",