Skip to content

Commit 9a2fbc5

Browse files
runningcodeclaude
andcommitted
test(spring-boot): Move the Boot 3 sample's Person test into the module
Adds an integrationTest suite to sentry-spring-boot-jakarta that runs a Spring Boot app in the test's JVM against MockSentryServer, and ports the jakarta sample's PersonSystemTest to it: errors with feature flags, the server transaction and its spans, logs, the JDBC query span and the profile linked to the transaction. It is a suite of its own because the unit tests' classpath carries the OpenTelemetry agent, which switches Sentry into agent mode. Test classes share one application context and one server, so the app starts once per suite. The Spring Boot 3 matrix now runs both of the module's integration suites against every Boot version it covers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 7cb3592 commit 9a2fbc5

7 files changed

Lines changed: 260 additions & 110 deletions

File tree

‎.github/workflows/spring-boot-3-matrix.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ jobs:
8383
:sentry-samples:sentry-samples-spring-jakarta:testClasses \
8484
:sentry-opentelemetry:sentry-opentelemetry-agent:assemble
8585
86+
- name: Test sentry-spring-boot-jakarta integration suites
87+
run: |
88+
./gradlew \
89+
:sentry-spring-boot-jakarta:integrationTest \
90+
:sentry-spring-boot-jakarta:integrationTestOtelAgent
91+
8692
- name: Test sentry-samples-spring-boot-jakarta
8793
run: |
8894
python3 test/system-test-runner.py test \

‎sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt‎

Lines changed: 0 additions & 109 deletions
This file was deleted.

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,27 @@ dependencies {
172172

173173
testing {
174174
suites {
175+
// Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own
176+
// because the unit tests' classpath carries the agent, which switches Sentry into agent mode.
177+
register<JvmTestSuite>("integrationTest") {
178+
useJUnit()
179+
dependencies {
180+
implementation(platform(SpringBootPlugin.BOM_COORDINATES))
181+
implementation(projects.sentrySpringBootStarterJakarta)
182+
implementation(projects.sentryAsyncProfiler)
183+
implementation(projects.sentryJdbc)
184+
implementation(projects.sentryLogback)
185+
implementation(projects.sentryTestSupport)
186+
implementation(libs.kotlin.test.junit)
187+
implementation(libs.google.truth)
188+
implementation(libs.springboot3.starter.aop)
189+
implementation(libs.springboot3.starter.jdbc)
190+
implementation(libs.springboot3.starter.test)
191+
implementation(libs.springboot3.starter.web)
192+
runtimeOnly(libs.hsqldb)
193+
}
194+
}
195+
175196
register<JvmTestSuite>("integrationTestOtelAgent") {
176197
useJUnit()
177198
dependencies {
@@ -193,4 +214,9 @@ testing {
193214
}
194215
}
195216

196-
tasks.named("check") { dependsOn(testing.suites.named("integrationTestOtelAgent")) }
217+
tasks.named("check") {
218+
dependsOn(
219+
testing.suites.named("integrationTest"),
220+
testing.suites.named("integrationTestOtelAgent"),
221+
)
222+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package io.sentry.spring.boot.jakarta.integrationtest
2+
3+
import io.sentry.test.MockSentryServer
4+
import org.junit.Before
5+
import org.junit.runner.RunWith
6+
import org.springframework.beans.factory.annotation.Autowired
7+
import org.springframework.boot.test.context.SpringBootTest
8+
import org.springframework.boot.test.web.client.TestRestTemplate
9+
import org.springframework.test.context.DynamicPropertyRegistry
10+
import org.springframework.test.context.DynamicPropertySource
11+
import org.springframework.test.context.junit4.SpringRunner
12+
13+
/**
14+
* Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server].
15+
*
16+
* Subclasses share one application context and one server, so the app starts once per suite.
17+
* Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they
18+
* produce.
19+
*/
20+
@RunWith(SpringRunner::class)
21+
@SpringBootTest(
22+
classes = [IntegrationTestApplication::class],
23+
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
24+
properties =
25+
[
26+
"sentry.traces-sample-rate=1.0",
27+
"sentry.logs.enabled=true",
28+
"sentry.profile-session-sample-rate=1.0",
29+
"sentry.profile-lifecycle=TRACE",
30+
"sentry.profiling-traces-dir-path=\${java.io.tmpdir}/sentry-integration-test/profiling-traces",
31+
"spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb",
32+
"spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver",
33+
],
34+
)
35+
abstract class IntegrationTest {
36+
@Autowired lateinit var restTemplate: TestRestTemplate
37+
38+
@Before
39+
fun resetServer() {
40+
server.reset()
41+
}
42+
43+
companion object {
44+
val server = MockSentryServer()
45+
46+
@JvmStatic
47+
@DynamicPropertySource
48+
fun sentryDsn(registry: DynamicPropertyRegistry) {
49+
registry.add("sentry.dsn") { server.dsn }
50+
}
51+
}
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package io.sentry.spring.boot.jakarta.integrationtest
2+
3+
import io.sentry.Sentry
4+
import io.sentry.spring.jakarta.tracing.SentrySpan
5+
import org.slf4j.LoggerFactory
6+
import org.springframework.boot.autoconfigure.SpringBootApplication
7+
import org.springframework.jdbc.core.JdbcTemplate
8+
import org.springframework.stereotype.Service
9+
import org.springframework.web.bind.annotation.GetMapping
10+
import org.springframework.web.bind.annotation.PathVariable
11+
import org.springframework.web.bind.annotation.PostMapping
12+
import org.springframework.web.bind.annotation.RequestBody
13+
import org.springframework.web.bind.annotation.RequestMapping
14+
import org.springframework.web.bind.annotation.RestController
15+
16+
// Classes and methods are open because Spring proxies them and this module doesn't apply the
17+
// kotlin-spring plugin.
18+
19+
@SpringBootApplication open class IntegrationTestApplication
20+
21+
open class Person(var firstName: String? = null, var lastName: String? = null)
22+
23+
@RestController
24+
@RequestMapping("/person/")
25+
open class PersonController(private val personService: PersonService) {
26+
private val logger = LoggerFactory.getLogger(PersonController::class.java)
27+
28+
/** Reports to Sentry through every API an app uses, then fails the request. */
29+
@GetMapping("{id}")
30+
open fun person(@PathVariable("id") id: Long): Person {
31+
Sentry.addFeatureFlag("transaction-feature-flag", true)
32+
val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi")
33+
try {
34+
Sentry.setAttribute("user.type", "admin")
35+
Sentry.setAttribute("feature.version", 2)
36+
Sentry.setAttribute("debug.enabled", true)
37+
Sentry.logger().warn("warn Sentry logging")
38+
Sentry.logger().error("error Sentry logging")
39+
Sentry.logger().info("hello %s %s", "there", "world!")
40+
Sentry.addFeatureFlag("my-feature-flag", true)
41+
logger.error("Trying person with id={}", id, RuntimeException("error while loading"))
42+
throw IllegalArgumentException("Something went wrong [id=$id]")
43+
} finally {
44+
sentrySpan.finish()
45+
}
46+
}
47+
48+
@PostMapping
49+
open fun create(@RequestBody person: Person): Person {
50+
val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi")
51+
try {
52+
return personService.create(person)
53+
} finally {
54+
sentrySpan.finish()
55+
}
56+
}
57+
}
58+
59+
@Service
60+
@SentrySpan
61+
open class PersonService(private val jdbcTemplate: JdbcTemplate) {
62+
open fun create(person: Person): Person {
63+
jdbcTemplate.update(
64+
"insert into person (firstName, lastName) values (?, ?)",
65+
person.firstName,
66+
person.lastName,
67+
)
68+
return person
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package io.sentry.spring.boot.jakarta.integrationtest
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import io.sentry.Sentry
5+
import io.sentry.protocol.FeatureFlag
6+
import io.sentry.test.event
7+
import io.sentry.test.hasFlag
8+
import io.sentry.test.hasLogWithAttribute
9+
import io.sentry.test.hasLogWithBody
10+
import io.sentry.test.hasOp
11+
import io.sentry.test.hasSpanWithOp
12+
import io.sentry.test.hasSpanWithOpAndDescription
13+
import io.sentry.test.logs
14+
import io.sentry.test.profileChunk
15+
import io.sentry.test.transaction
16+
import kotlin.test.Test
17+
18+
class PersonIntegrationTest : IntegrationTest() {
19+
20+
@Test
21+
fun `failed request reports errors, the transaction and logs`() {
22+
val response = restTemplate.getForEntity("/person/1", String::class.java)
23+
assertThat(response.statusCode.value()).isEqualTo(500)
24+
Sentry.flush(5000)
25+
26+
val logged =
27+
server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!!
28+
assertThat(logged.hasFlag("my-feature-flag", true)).isTrue()
29+
30+
val thrown =
31+
server
32+
.awaitEnvelope {
33+
it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true
34+
}
35+
.event()!!
36+
assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue()
37+
38+
val transaction =
39+
server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!!
40+
assertThat(
41+
transaction.hasOp(
42+
"http.server",
43+
FeatureFlag("flag.evaluation.transaction-feature-flag", true),
44+
)
45+
)
46+
.isTrue()
47+
assertThat(
48+
transaction.hasSpanWithOp(
49+
"spanCreatedThroughSentryApi",
50+
FeatureFlag("flag.evaluation.my-feature-flag", true),
51+
)
52+
)
53+
.isTrue()
54+
55+
val logs =
56+
server
57+
.awaitEnvelope {
58+
it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true
59+
}
60+
.logs()!!
61+
assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue()
62+
assertThat(logs.hasLogWithBody("hello there world!")).isTrue()
63+
assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue()
64+
assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue()
65+
assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue()
66+
}
67+
68+
@Test
69+
fun `creating a person traces the service method and the query`() {
70+
val response =
71+
restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java)
72+
assertThat(response.statusCode.value()).isEqualTo(200)
73+
assertThat(response.body!!.firstName).isEqualTo("firstA")
74+
assertThat(response.body!!.lastName).isEqualTo("lastB")
75+
76+
val transaction =
77+
server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!!
78+
assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue()
79+
assertThat(
80+
transaction.hasSpanWithOpAndDescription(
81+
"db.query",
82+
"insert into person (firstName, lastName) values (?, ?)",
83+
)
84+
)
85+
.isTrue()
86+
}
87+
88+
@Test
89+
fun `creating a person starts a profile linked to the transaction`() {
90+
val response =
91+
restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java)
92+
assertThat(response.statusCode.value()).isEqualTo(200)
93+
94+
val transaction =
95+
server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!!
96+
val profilerId = transaction.contexts.profile?.profilerId
97+
assertThat(profilerId).isNotNull()
98+
server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId }
99+
}
100+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE TABLE person (
2+
id INTEGER IDENTITY PRIMARY KEY,
3+
firstName VARCHAR(50) NOT NULL,
4+
lastName VARCHAR(50) NOT NULL
5+
);

0 commit comments

Comments
 (0)