Skip to content

Commit 165176a

Browse files
committed
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 c518635 commit 165176a

7 files changed

Lines changed: 254 additions & 109 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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,27 @@ dependencies {
170170

171171
testing {
172172
suites {
173+
// Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own
174+
// because the unit tests' classpath carries the agent, which switches Sentry into agent mode.
175+
register<JvmTestSuite>("integrationTest") {
176+
useJUnit()
177+
dependencies {
178+
implementation(platform(SpringBootPlugin.BOM_COORDINATES))
179+
implementation(projects.sentrySpringBootStarterJakarta)
180+
implementation(projects.sentryAsyncProfiler)
181+
implementation(projects.sentryJdbc)
182+
implementation(projects.sentryLogback)
183+
implementation(projects.sentryTestSupport)
184+
implementation(libs.kotlin.test.junit)
185+
implementation(libs.google.truth)
186+
implementation(libs.springboot3.starter.aop)
187+
implementation(libs.springboot3.starter.jdbc)
188+
implementation(libs.springboot3.starter.test)
189+
implementation(libs.springboot3.starter.web)
190+
runtimeOnly(libs.hsqldb)
191+
}
192+
}
193+
173194
register<JvmTestSuite>("integrationTestOtelAgent") {
174195
useJUnit()
175196
dependencies {
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)