From 38d8621f93ffea4b6fc411aa490da8602930852c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 15 Oct 2026 15:13:18 +0200 Subject: [PATCH] test(otel): Add a smoke test that runs an app with the built agent The agent had no tests; only the sample system tests exercised it. The test launches a JDK-only app in a child JVM with the agent attached and asserts that an incoming request reaches MockSentryServer as an http.server transaction from the agent SDK. The app runs in a child JVM so the test's own JVM stays uninstrumented. Otherwise the agent would turn the test's HTTP calls and the mock server into spans. The agent jar is wired as a test input, so the test reruns whenever the jar changes. Adding Kotlin for the test enrolls the module in API validation, hence the new API dump. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/sentry-opentelemetry-agent.api | 4 + .../build.gradle.kts | 27 ++++++ .../agent/fixture/HelloServer.java | 34 +++++++ .../opentelemetry/agent/AgentSmokeTest.kt | 88 +++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api create mode 100644 sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java create mode 100644 sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api b/sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api new file mode 100644 index 00000000000..eab018dee5d --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api @@ -0,0 +1,4 @@ +public final class io/sentry/opentelemetry/agent/AgentMarker { + public fun ()V +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index 054db790dc2..a4ed3235e68 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -1,12 +1,18 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.animalsniffer") id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.shadow) } +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + fun relocatePackages(shadowJar: ShadowJar) { // rewrite dependencies calling Logger.getLogger shadowJar.relocate("java.util.logging.Logger", "io.opentelemetry.javaagent.bootstrap.PatchLogger") @@ -76,6 +82,11 @@ dependencies { bootstrapLibs(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) javaagentLibs(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) upstreamAgent(libs.otel.javaagent) + + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) } fun isolateClasses(jars: Iterable): CopySpec = copySpec { @@ -197,6 +208,22 @@ tasks { } assemble { dependsOn(shadowJar) } + + test { + jvmArgumentProviders.add( + objects.newInstance().apply { + agentJar.set(shadowJar.flatMap { it.archiveFile }) + } + ) + } +} + +// Tests launch apps with the built agent attached, so the agent jar is a test input. +abstract class AgentJarArgument : CommandLineArgumentProvider { + @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val agentJar: RegularFileProperty + + override fun asArguments() = + listOf("-Dsentry.test.agentJar=${agentJar.get().asFile.absolutePath}") } tasks.named("distZip").configure { this.dependsOn("jar") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java new file mode 100644 index 00000000000..78d322c3d31 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java @@ -0,0 +1,34 @@ +package io.sentry.opentelemetry.agent.fixture; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + +/** + * An app for the agent to instrument. It only uses the JDK, so a test can run it with nothing but + * this class on the classpath, and it writes its port to the file named by the first argument. + */ +public final class HelloServer { + public static void main(final String[] args) throws IOException { + final HttpServer server = + HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 0); + server.createContext( + "/hello", + exchange -> { + final byte[] body = "hello".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + Files.write( + Paths.get(args[0]), + String.valueOf(server.getAddress().getPort()).getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt new file mode 100644 index 00000000000..824e31c34fd --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt @@ -0,0 +1,88 @@ +package io.sentry.opentelemetry.agent + +import com.google.common.truth.Truth.assertThat +import io.sentry.opentelemetry.agent.fixture.HelloServer +import io.sentry.test.MockSentryServer +import io.sentry.test.transaction +import io.sentry.time.Deadline +import io.sentry.time.JavaMonotonicTicker +import java.io.File +import java.net.URL +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * Runs an app with the built agent attached, in a child JVM, so the test's own JVM stays + * uninstrumented. Instrumenting it would turn the test's HTTP calls and [MockSentryServer] itself + * into spans. + */ +class AgentSmokeTest { + private val server = MockSentryServer() + private val workDir = Files.createTempDirectory("agent-smoke-test").toFile() + private val appLog = File(workDir, "app.log") + private var app: Process? = null + + @AfterTest + fun teardown() { + app?.destroyForcibly()?.waitFor() + server.close() + if (appLog.exists()) { + println(appLog.readText()) + } + workDir.deleteRecursively() + } + + @Test + fun `agent sends a transaction for an incoming request`() { + val port = launchWithAgent(HelloServer::class.java) + + URL("http://127.0.0.1:$port/hello").readText() + + // The agent's BatchSpanProcessor exports every 5s. + val envelope = + server.awaitEnvelope(timeoutMillis = 30_000) { + it.transaction()?.contexts?.trace?.operation == "http.server" + } + assertThat(envelope.header.sdkVersion!!.name).isEqualTo("sentry.java.opentelemetry.agent") + } + + private fun launchWithAgent(mainClass: Class<*>): Int { + val portFile = File(workDir, "port") + val process = + ProcessBuilder( + File(System.getProperty("java.home"), "bin/java").path, + "-javaagent:${System.getProperty("sentry.test.agentJar")}", + "-cp", + File(mainClass.protectionDomain.codeSource.location.toURI()).path, + mainClass.name, + portFile.path, + ) + .redirectErrorStream(true) + .redirectOutput(appLog) + .apply { + environment()["SENTRY_DSN"] = server.dsn + environment()["SENTRY_TRACES_SAMPLE_RATE"] = "1.0" + environment()["OTEL_TRACES_EXPORTER"] = "none" + environment()["OTEL_METRICS_EXPORTER"] = "none" + environment()["OTEL_LOGS_EXPORTER"] = "none" + } + .start() + app = process + + val deadline = Deadline.after(JavaMonotonicTicker.getInstance(), 60, TimeUnit.SECONDS) + while (true) { + portFile + .takeIf { it.exists() } + ?.readText() + ?.toIntOrNull() + ?.let { + return it + } + check(process.isAlive) { "App exited with ${process.exitValue()}" } + check(!deadline.hasPassed()) { "App did not start within 60s" } + Thread.sleep(100) + } + } +}