From b6735fed5f76c84bfb2997194890e7482f80b098 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 15 Oct 2026 15:58:09 +0200 Subject: [PATCH] test(otel): Test a Spring Boot app under the agent in both init modes Runs a Spring Boot app with the built agent in a child JVM and checks that errors, the server transaction with OTel and Sentry API spans, and logs reach MockSentryServer. It runs once with the agent initializing Sentry and once with the Spring Boot starter doing it; the SDK name proves which path ran. The sample's auto-init=false CI variant shares task inputs with the true variant, so Gradle can skip it as up to date. The app has its own source set so it runs on an app-like classpath rather than the test's, which carries the agent's plain jar and would flip the auto-configuration conditions. The child-JVM launcher moves into sentry-test-support and the argument provider into build-logic, so both agent tests share them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../io/sentry/gradle/ChildJvmTestArguments.kt | 30 +++++ .../build.gradle.kts | 13 +- .../agent/fixture/HelloServer.java | 4 +- .../opentelemetry/agent/AgentSmokeTest.kt | 72 +++-------- sentry-spring-boot-jakarta/build.gradle.kts | 53 ++++++++ .../agentfixture/AgentFixtureApplication.kt | 56 +++++++++ .../boot/jakarta/OtelAgentPersonTest.kt | 119 ++++++++++++++++++ .../main/kotlin/io/sentry/test/ChildJvmApp.kt | 72 +++++++++++ 8 files changed, 354 insertions(+), 65 deletions(-) create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt create mode 100644 sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt create mode 100644 sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt b/build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt new file mode 100644 index 0000000000..84335b938f --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt @@ -0,0 +1,30 @@ +package io.sentry.gradle + +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.process.CommandLineArgumentProvider + +/** + * Tells a test task's JVM where to find the built OpenTelemetry agent and the app to run with it. + * + * The test launches the app in a child JVM, so neither file is on the test classpath. Declaring + * them here makes them task inputs: the test reruns when either changes and depends on the tasks + * that build them. + */ +abstract class ChildJvmTestArguments : CommandLineArgumentProvider { + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + abstract val agentJar: ConfigurableFileCollection + + @get:Classpath abstract val appClasspath: ConfigurableFileCollection + + override fun asArguments(): List = buildList { + add("-Dsentry.test.agentJar=${agentJar.singleFile.absolutePath}") + if (!appClasspath.isEmpty) { + add("-Dsentry.test.appClasspath=${appClasspath.asPath}") + } + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index 141f848793..b4b1a8b840 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -1,4 +1,5 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import io.sentry.gradle.ChildJvmTestArguments import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -211,19 +212,9 @@ tasks { test { jvmArgumentProviders.add( - objects.newInstance().apply { - agentJar.set(shadowJar.flatMap { it.archiveFile }) - } + objects.newInstance().apply { agentJar.from(shadowJar) } ) } } -// 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 index 78d322c3d3..b03287568d 100644 --- 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 @@ -11,7 +11,7 @@ /** * 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. + * this class on the classpath, and it writes its port to the file named by {@code PORTFILE}. */ public final class HelloServer { public static void main(final String[] args) throws IOException { @@ -28,7 +28,7 @@ public static void main(final String[] args) throws IOException { }); server.start(); Files.write( - Paths.get(args[0]), + Paths.get(System.getenv("PORTFILE")), 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 index 824e31c34f..2764a7d183 100644 --- 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 @@ -2,14 +2,11 @@ package io.sentry.opentelemetry.agent import com.google.common.truth.Truth.assertThat import io.sentry.opentelemetry.agent.fixture.HelloServer +import io.sentry.test.ChildJvmApp 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 @@ -20,25 +17,34 @@ import kotlin.test.Test */ 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 + private var app: ChildJvmApp? = null @AfterTest fun teardown() { - app?.destroyForcibly()?.waitFor() + app?.close() 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) + val app = + ChildJvmApp.start( + mainClass = HelloServer::class.java.name, + classpath = + File(HelloServer::class.java.protectionDomain.codeSource.location.toURI()).path, + jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), + env = + mapOf( + "SENTRY_DSN" to server.dsn, + "SENTRY_TRACES_SAMPLE_RATE" to "1.0", + "OTEL_TRACES_EXPORTER" to "none", + "OTEL_METRICS_EXPORTER" to "none", + "OTEL_LOGS_EXPORTER" to "none", + ), + ) + .also { app = it } - URL("http://127.0.0.1:$port/hello").readText() + URL("http://127.0.0.1:${app.awaitPort()}/hello").readText() // The agent's BatchSpanProcessor exports every 5s. val envelope = @@ -47,42 +53,4 @@ class AgentSmokeTest { } 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) - } - } } diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 8c1a07aa9c..ae862a9a5b 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -1,3 +1,4 @@ +import io.sentry.gradle.ChildJvmTestArguments import net.ltgt.gradle.errorprone.errorprone import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.plugin.SpringBootPlugin @@ -139,3 +140,55 @@ tasks.jar { ) } } + +// The OpenTelemetry agent tests run this app in a child JVM, on its own classpath like a real app +// with the agent attached, rather than on the test's classpath. +val agentFixture = sourceSets.create("agentFixture") + +// The built agent jar, rather than the plain jar the unit tests compile against. +val otelAgentJar = + configurations.create("otelAgentJar") { + isCanBeResolved = true + isCanBeConsumed = false + isTransitive = false + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.SHADOWED)) + } + } + +dependencies { + "agentFixtureImplementation"(platform(SpringBootPlugin.BOM_COORDINATES)) + "agentFixtureImplementation"(projects.sentrySpringBootStarterJakarta) + "agentFixtureImplementation"(projects.sentryLogback) + "agentFixtureImplementation"(kotlin(Config.kotlinStdLib)) + "agentFixtureImplementation"(libs.otel) + "agentFixtureImplementation"(libs.springboot3.starter.web) + + otelAgentJar(projects.sentryOpentelemetry.sentryOpentelemetryAgent) +} + +testing { + suites { + register("integrationTestOtelAgent") { + useJUnit() + dependencies { + implementation(projects.sentryTestSupport) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + } + targets.all { + testTask.configure { + jvmArgumentProviders.add( + objects.newInstance().apply { + agentJar.from(otelAgentJar) + appClasspath.from(agentFixture.runtimeClasspath) + } + ) + } + } + } + } +} + +tasks.named("check") { dependsOn(testing.suites.named("integrationTestOtelAgent")) } diff --git a/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt new file mode 100644 index 0000000000..d857274ded --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/AgentFixtureApplication.kt @@ -0,0 +1,56 @@ +package io.sentry.spring.boot.jakarta.agentfixture + +import io.opentelemetry.api.GlobalOpenTelemetry +import io.opentelemetry.api.trace.Tracer +import io.sentry.Sentry +import org.slf4j.LoggerFactory +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.web.context.WebServerPortFileWriter +import org.springframework.context.annotation.Bean +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController + +@SpringBootApplication +open class AgentFixtureApplication { + @Bean open fun tracer(): Tracer = GlobalOpenTelemetry.get().getTracer("agent-fixture") +} + +fun main(args: Array) { + SpringApplication(AgentFixtureApplication::class.java) + .apply { addListeners(WebServerPortFileWriter()) } + .run(*args) +} + +/** Reports to Sentry through every API an app with the agent uses, then fails the request. */ +@RestController +open class PersonController(private val tracer: Tracer) { + private val logger = LoggerFactory.getLogger(PersonController::class.java) + + @GetMapping("/person/{id}") + open fun person(@PathVariable("id") id: Long): String { + Sentry.addFeatureFlag("transaction-feature-flag", true) + val span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan() + try { + span.makeCurrent().use { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.logger().warn("warn Sentry logging") + Sentry.logger().error("error Sentry logging") + Sentry.logger().info("hello %s %s", "there", "world!") + Sentry.addFeatureFlag("my-feature-flag", true) + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + logger.error("Trying person with id={}", id, RuntimeException("error while loading")) + throw IllegalArgumentException("Something went wrong [id=$id]") + } finally { + sentrySpan.finish() + } + } + } finally { + span.end() + } + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt new file mode 100644 index 0000000000..9ade405a8f --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/OtelAgentPersonTest.kt @@ -0,0 +1,119 @@ +package io.sentry.spring.boot.jakarta + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.FeatureFlag +import io.sentry.test.ChildJvmApp +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasOp +import io.sentry.test.hasSpanWithOp +import io.sentry.test.logs +import io.sentry.test.transaction +import java.net.HttpURLConnection +import java.net.URL +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * Runs a Spring Boot app with the built OpenTelemetry agent in a child JVM. Sentry is initialized + * either by the agent or, with `SENTRY_AUTO_INIT=false`, by the Spring Boot starter. + */ +class OtelAgentPersonTest { + private val server = MockSentryServer() + private var app: ChildJvmApp? = null + + @AfterTest + fun teardown() { + app?.close() + server.close() + } + + @Test + fun `failed request is reported when the agent initializes Sentry`() { + assertFailedRequestIsReported(autoInit = true, sdkName = "sentry.java.opentelemetry.agent") + } + + @Test + fun `failed request is reported when the Spring Boot starter initializes Sentry`() { + assertFailedRequestIsReported(autoInit = false, sdkName = "sentry.java.spring-boot.jakarta") + } + + private fun assertFailedRequestIsReported(autoInit: Boolean, sdkName: String) { + val port = startApp(autoInit) + + val connection = URL("http://127.0.0.1:$port/person/1").openConnection() as HttpURLConnection + assertThat(connection.responseCode).isEqualTo(500) + + val logged = + server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() + + val thrown = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + } + .event()!! + assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() + + // The agent's BatchSpanProcessor exports every 5s. + val transactionEnvelope = + server.awaitEnvelope(timeoutMillis = 30_000) { + it.transaction()?.contexts?.trace?.operation == "http.server" + } + assertThat(transactionEnvelope.header.sdkVersion!!.name).isEqualTo(sdkName) + val transaction = transactionEnvelope.transaction()!! + assertThat( + transaction.hasOp( + "http.server", + FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) + ) + .isTrue() + assertThat( + transaction.hasSpanWithOp( + "spanCreatedThroughOtelApi", + FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + ) + .isTrue() + assertThat(transaction.hasSpanWithOp("spanCreatedThroughSentryApi")).isTrue() + + val logs = + server + .awaitEnvelope(timeoutMillis = 30_000) { + it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true + } + .logs()!! + assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue() + assertThat(logs.hasLogWithBody("hello there world!")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue() + } + + private fun startApp(autoInit: Boolean): Int { + val app = + ChildJvmApp.start( + mainClass = "io.sentry.spring.boot.jakarta.agentfixture.AgentFixtureApplicationKt", + classpath = System.getProperty("sentry.test.appClasspath"), + jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), + env = + mapOf( + "SENTRY_AUTO_INIT" to autoInit.toString(), + "SENTRY_DSN" to server.dsn, + "SENTRY_TRACES_SAMPLE_RATE" to "1.0", + "SENTRY_LOGS_ENABLED" to "true", + "OTEL_TRACES_EXPORTER" to "none", + "OTEL_METRICS_EXPORTER" to "none", + "OTEL_LOGS_EXPORTER" to "none", + ), + args = listOf("--server.port=0"), + ) + this.app = app + return app.awaitPort() + } +} diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt new file mode 100644 index 0000000000..c9c529d45c --- /dev/null +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt @@ -0,0 +1,72 @@ +package io.sentry.test + +import io.sentry.time.Deadline +import io.sentry.time.JavaMonotonicTicker +import java.io.Closeable +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +/** + * An app running in a child JVM, for apps that must not share the test's JVM, such as one with the + * OpenTelemetry agent attached. + * + * The app writes the port it listens on to the file named by the `PORTFILE` environment variable, + * which Spring Boot's `WebServerPortFileWriter` reads too. [close] kills the app and prints its + * output, which otherwise only lives in a temporary file. + */ +class ChildJvmApp private constructor(private val process: Process, private val workDir: File) : + Closeable { + private val portFile = File(workDir, PORT_FILE) + + /** Waits until the app has written its port and returns it. */ + fun awaitPort(timeoutSeconds: Long = 60): Int { + val deadline = + Deadline.after(JavaMonotonicTicker.getInstance(), timeoutSeconds, TimeUnit.SECONDS) + while (true) { + // The app may not have finished writing the file yet, in which case it doesn't parse. + val port = portFile.takeIf { it.exists() }?.readText()?.trim()?.toIntOrNull() + if (port != null) { + return port + } + check(process.isAlive) { "App exited with ${process.exitValue()}" } + check(!deadline.hasPassed()) { "App did not start within ${timeoutSeconds}s" } + Thread.sleep(100) + } + } + + override fun close() { + process.destroyForcibly().waitFor() + val log = File(workDir, LOG_FILE) + if (log.exists()) { + println(log.readText()) + } + workDir.deleteRecursively() + } + + companion object { + private const val PORT_FILE = "port" + private const val LOG_FILE = "app.log" + + fun start( + mainClass: String, + classpath: String, + jvmArgs: List = emptyList(), + env: Map = emptyMap(), + args: List = emptyList(), + ): ChildJvmApp { + val workDir = Files.createTempDirectory("child-jvm-app").toFile() + val java = File(System.getProperty("java.home"), "bin/java").path + val process = + ProcessBuilder(listOf(java) + jvmArgs + listOf("-cp", classpath, mainClass) + args) + .redirectErrorStream(true) + .redirectOutput(File(workDir, LOG_FILE)) + .apply { + environment().putAll(env) + environment()["PORTFILE"] = File(workDir, PORT_FILE).path + } + .start() + return ChildJvmApp(process, workDir) + } + } +}