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) + } + } +}