Skip to content

Commit 7cb3592

Browse files
runningcodeclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 38d8621 commit 7cb3592

8 files changed

Lines changed: 354 additions & 65 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package io.sentry.gradle
2+
3+
import org.gradle.api.file.ConfigurableFileCollection
4+
import org.gradle.api.tasks.Classpath
5+
import org.gradle.api.tasks.InputFiles
6+
import org.gradle.api.tasks.PathSensitive
7+
import org.gradle.api.tasks.PathSensitivity
8+
import org.gradle.process.CommandLineArgumentProvider
9+
10+
/**
11+
* Tells a test task's JVM where to find the built OpenTelemetry agent and the app to run with it.
12+
*
13+
* The test launches the app in a child JVM, so neither file is on the test classpath. Declaring
14+
* them here makes them task inputs: the test reruns when either changes and depends on the tasks
15+
* that build them.
16+
*/
17+
abstract class ChildJvmTestArguments : CommandLineArgumentProvider {
18+
@get:InputFiles
19+
@get:PathSensitive(PathSensitivity.NONE)
20+
abstract val agentJar: ConfigurableFileCollection
21+
22+
@get:Classpath abstract val appClasspath: ConfigurableFileCollection
23+
24+
override fun asArguments(): List<String> = buildList {
25+
add("-Dsentry.test.agentJar=${agentJar.singleFile.absolutePath}")
26+
if (!appClasspath.isEmpty) {
27+
add("-Dsentry.test.appClasspath=${appClasspath.asPath}")
28+
}
29+
}
30+
}

‎sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
2+
import io.sentry.gradle.ChildJvmTestArguments
23
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
34

45
plugins {
@@ -211,19 +212,9 @@ tasks {
211212

212213
test {
213214
jvmArgumentProviders.add(
214-
objects.newInstance<AgentJarArgument>().apply {
215-
agentJar.set(shadowJar.flatMap { it.archiveFile })
216-
}
215+
objects.newInstance<ChildJvmTestArguments>().apply { agentJar.from(shadowJar) }
217216
)
218217
}
219218
}
220219

221-
// Tests launch apps with the built agent attached, so the agent jar is a test input.
222-
abstract class AgentJarArgument : CommandLineArgumentProvider {
223-
@get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val agentJar: RegularFileProperty
224-
225-
override fun asArguments() =
226-
listOf("-Dsentry.test.agentJar=${agentJar.get().asFile.absolutePath}")
227-
}
228-
229220
tasks.named("distZip").configure { this.dependsOn("jar") }

‎sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
/**
1313
* An app for the agent to instrument. It only uses the JDK, so a test can run it with nothing but
14-
* this class on the classpath, and it writes its port to the file named by the first argument.
14+
* this class on the classpath, and it writes its port to the file named by {@code PORTFILE}.
1515
*/
1616
public final class HelloServer {
1717
public static void main(final String[] args) throws IOException {
@@ -28,7 +28,7 @@ public static void main(final String[] args) throws IOException {
2828
});
2929
server.start();
3030
Files.write(
31-
Paths.get(args[0]),
31+
Paths.get(System.getenv("PORTFILE")),
3232
String.valueOf(server.getAddress().getPort()).getBytes(StandardCharsets.UTF_8));
3333
}
3434
}

‎sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt‎

Lines changed: 20 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,11 @@ package io.sentry.opentelemetry.agent
22

33
import com.google.common.truth.Truth.assertThat
44
import io.sentry.opentelemetry.agent.fixture.HelloServer
5+
import io.sentry.test.ChildJvmApp
56
import io.sentry.test.MockSentryServer
67
import io.sentry.test.transaction
7-
import io.sentry.time.Deadline
8-
import io.sentry.time.JavaMonotonicTicker
98
import java.io.File
109
import java.net.URL
11-
import java.nio.file.Files
12-
import java.util.concurrent.TimeUnit
1310
import kotlin.test.AfterTest
1411
import kotlin.test.Test
1512

@@ -20,25 +17,34 @@ import kotlin.test.Test
2017
*/
2118
class AgentSmokeTest {
2219
private val server = MockSentryServer()
23-
private val workDir = Files.createTempDirectory("agent-smoke-test").toFile()
24-
private val appLog = File(workDir, "app.log")
25-
private var app: Process? = null
20+
private var app: ChildJvmApp? = null
2621

2722
@AfterTest
2823
fun teardown() {
29-
app?.destroyForcibly()?.waitFor()
24+
app?.close()
3025
server.close()
31-
if (appLog.exists()) {
32-
println(appLog.readText())
33-
}
34-
workDir.deleteRecursively()
3526
}
3627

3728
@Test
3829
fun `agent sends a transaction for an incoming request`() {
39-
val port = launchWithAgent(HelloServer::class.java)
30+
val app =
31+
ChildJvmApp.start(
32+
mainClass = HelloServer::class.java.name,
33+
classpath =
34+
File(HelloServer::class.java.protectionDomain.codeSource.location.toURI()).path,
35+
jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"),
36+
env =
37+
mapOf(
38+
"SENTRY_DSN" to server.dsn,
39+
"SENTRY_TRACES_SAMPLE_RATE" to "1.0",
40+
"OTEL_TRACES_EXPORTER" to "none",
41+
"OTEL_METRICS_EXPORTER" to "none",
42+
"OTEL_LOGS_EXPORTER" to "none",
43+
),
44+
)
45+
.also { app = it }
4046

41-
URL("http://127.0.0.1:$port/hello").readText()
47+
URL("http://127.0.0.1:${app.awaitPort()}/hello").readText()
4248

4349
// The agent's BatchSpanProcessor exports every 5s.
4450
val envelope =
@@ -47,42 +53,4 @@ class AgentSmokeTest {
4753
}
4854
assertThat(envelope.header.sdkVersion!!.name).isEqualTo("sentry.java.opentelemetry.agent")
4955
}
50-
51-
private fun launchWithAgent(mainClass: Class<*>): Int {
52-
val portFile = File(workDir, "port")
53-
val process =
54-
ProcessBuilder(
55-
File(System.getProperty("java.home"), "bin/java").path,
56-
"-javaagent:${System.getProperty("sentry.test.agentJar")}",
57-
"-cp",
58-
File(mainClass.protectionDomain.codeSource.location.toURI()).path,
59-
mainClass.name,
60-
portFile.path,
61-
)
62-
.redirectErrorStream(true)
63-
.redirectOutput(appLog)
64-
.apply {
65-
environment()["SENTRY_DSN"] = server.dsn
66-
environment()["SENTRY_TRACES_SAMPLE_RATE"] = "1.0"
67-
environment()["OTEL_TRACES_EXPORTER"] = "none"
68-
environment()["OTEL_METRICS_EXPORTER"] = "none"
69-
environment()["OTEL_LOGS_EXPORTER"] = "none"
70-
}
71-
.start()
72-
app = process
73-
74-
val deadline = Deadline.after(JavaMonotonicTicker.getInstance(), 60, TimeUnit.SECONDS)
75-
while (true) {
76-
portFile
77-
.takeIf { it.exists() }
78-
?.readText()
79-
?.toIntOrNull()
80-
?.let {
81-
return it
82-
}
83-
check(process.isAlive) { "App exited with ${process.exitValue()}" }
84-
check(!deadline.hasPassed()) { "App did not start within 60s" }
85-
Thread.sleep(100)
86-
}
87-
}
8856
}

‎sentry-spring-boot-jakarta/build.gradle.kts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import io.sentry.gradle.ChildJvmTestArguments
12
import net.ltgt.gradle.errorprone.errorprone
23
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
34
import org.springframework.boot.gradle.plugin.SpringBootPlugin
@@ -141,3 +142,55 @@ tasks.jar {
141142
)
142143
}
143144
}
145+
146+
// The OpenTelemetry agent tests run this app in a child JVM, on its own classpath like a real app
147+
// with the agent attached, rather than on the test's classpath.
148+
val agentFixture = sourceSets.create("agentFixture")
149+
150+
// The built agent jar, rather than the plain jar the unit tests compile against.
151+
val otelAgentJar =
152+
configurations.create("otelAgentJar") {
153+
isCanBeResolved = true
154+
isCanBeConsumed = false
155+
isTransitive = false
156+
attributes {
157+
attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
158+
attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.SHADOWED))
159+
}
160+
}
161+
162+
dependencies {
163+
"agentFixtureImplementation"(platform(SpringBootPlugin.BOM_COORDINATES))
164+
"agentFixtureImplementation"(projects.sentrySpringBootStarterJakarta)
165+
"agentFixtureImplementation"(projects.sentryLogback)
166+
"agentFixtureImplementation"(kotlin(Config.kotlinStdLib))
167+
"agentFixtureImplementation"(libs.otel)
168+
"agentFixtureImplementation"(libs.springboot3.starter.web)
169+
170+
otelAgentJar(projects.sentryOpentelemetry.sentryOpentelemetryAgent)
171+
}
172+
173+
testing {
174+
suites {
175+
register<JvmTestSuite>("integrationTestOtelAgent") {
176+
useJUnit()
177+
dependencies {
178+
implementation(projects.sentryTestSupport)
179+
implementation(libs.kotlin.test.junit)
180+
implementation(libs.google.truth)
181+
}
182+
targets.all {
183+
testTask.configure {
184+
jvmArgumentProviders.add(
185+
objects.newInstance<ChildJvmTestArguments>().apply {
186+
agentJar.from(otelAgentJar)
187+
appClasspath.from(agentFixture.runtimeClasspath)
188+
}
189+
)
190+
}
191+
}
192+
}
193+
}
194+
}
195+
196+
tasks.named("check") { dependsOn(testing.suites.named("integrationTestOtelAgent")) }
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package io.sentry.spring.boot.jakarta.agentfixture
2+
3+
import io.opentelemetry.api.GlobalOpenTelemetry
4+
import io.opentelemetry.api.trace.Tracer
5+
import io.sentry.Sentry
6+
import org.slf4j.LoggerFactory
7+
import org.springframework.boot.SpringApplication
8+
import org.springframework.boot.autoconfigure.SpringBootApplication
9+
import org.springframework.boot.web.context.WebServerPortFileWriter
10+
import org.springframework.context.annotation.Bean
11+
import org.springframework.web.bind.annotation.GetMapping
12+
import org.springframework.web.bind.annotation.PathVariable
13+
import org.springframework.web.bind.annotation.RestController
14+
15+
@SpringBootApplication
16+
open class AgentFixtureApplication {
17+
@Bean open fun tracer(): Tracer = GlobalOpenTelemetry.get().getTracer("agent-fixture")
18+
}
19+
20+
fun main(args: Array<String>) {
21+
SpringApplication(AgentFixtureApplication::class.java)
22+
.apply { addListeners(WebServerPortFileWriter()) }
23+
.run(*args)
24+
}
25+
26+
/** Reports to Sentry through every API an app with the agent uses, then fails the request. */
27+
@RestController
28+
open class PersonController(private val tracer: Tracer) {
29+
private val logger = LoggerFactory.getLogger(PersonController::class.java)
30+
31+
@GetMapping("/person/{id}")
32+
open fun person(@PathVariable("id") id: Long): String {
33+
Sentry.addFeatureFlag("transaction-feature-flag", true)
34+
val span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan()
35+
try {
36+
span.makeCurrent().use {
37+
Sentry.setAttribute("user.type", "admin")
38+
Sentry.setAttribute("feature.version", 2)
39+
Sentry.setAttribute("debug.enabled", true)
40+
Sentry.logger().warn("warn Sentry logging")
41+
Sentry.logger().error("error Sentry logging")
42+
Sentry.logger().info("hello %s %s", "there", "world!")
43+
Sentry.addFeatureFlag("my-feature-flag", true)
44+
val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi")
45+
try {
46+
logger.error("Trying person with id={}", id, RuntimeException("error while loading"))
47+
throw IllegalArgumentException("Something went wrong [id=$id]")
48+
} finally {
49+
sentrySpan.finish()
50+
}
51+
}
52+
} finally {
53+
span.end()
54+
}
55+
}
56+
}

0 commit comments

Comments
 (0)