Skip to content

Commit 7473271

Browse files
runningcodeclaude
andcommitted
test(spring-boot): Test the auto-configured Log4j2 appender end to end
The log4j2 Boot samples' tests were removed as covered elsewhere, but nothing else sends an event through the Log4j2 appender that Spring Boot's auto-configuration attaches. integrationTestLog4j2, in sentry-spring-boot-jakarta and sentry-spring-boot-4, runs a servlet app with Log4j2 instead of Logback and checks that an error logged through SLF4J reaches Sentry with the request's feature flag and the Log4j integration, as the samples' Person test did. Gradle's module replacement swaps the starters, as Spring Boot documents. It only acts on a classpath with both, which only this suite has. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent f2166ab commit 7473271

9 files changed

Lines changed: 260 additions & 2 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ jobs:
8585
run: |
8686
./gradlew \
8787
:sentry-spring-boot-jakarta:integrationTest \
88+
:sentry-spring-boot-jakarta:integrationTestLog4j2 \
8889
:sentry-spring-boot-jakarta:integrationTestOtelAgent
8990
9091
- name: Test sentry-samples-spring-boot-webflux-jakarta

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ jobs:
8282
:sentry-opentelemetry:sentry-opentelemetry-agent:assemble
8383
8484
- name: Test sentry-spring-boot-4 integration suite
85-
run: ./gradlew :sentry-spring-boot-4:integrationTest
85+
run: ./gradlew :sentry-spring-boot-4:integrationTest :sentry-spring-boot-4:integrationTestLog4j2
8686

8787
- name: Run sentry-samples-spring-boot-4-webflux
8888
run: |

‎build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,10 @@ tasks.register("integrationTests") {
225225
dependsOn(
226226
":sentry-spring-boot:integrationTest",
227227
":sentry-spring-boot-jakarta:integrationTest",
228+
":sentry-spring-boot-jakarta:integrationTestLog4j2",
228229
":sentry-spring-boot-jakarta:integrationTestOtelAgent",
229230
":sentry-spring-boot-4:integrationTest",
231+
":sentry-spring-boot-4:integrationTestLog4j2",
230232
)
231233
}
232234

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,19 @@ kotlin {
157157
}
158158
}
159159

160+
// Swaps Logback for Log4j2 where a classpath has both starters, which only
161+
// integrationTestLog4j2's has.
162+
dependencies {
163+
modules {
164+
module("org.springframework.boot:spring-boot-starter-logging") {
165+
replacedBy(
166+
"org.springframework.boot:spring-boot-starter-log4j2",
167+
"Use Log4j2 instead of Logback",
168+
)
169+
}
170+
}
171+
}
172+
160173
testing {
161174
suites {
162175
// Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own
@@ -195,11 +208,35 @@ testing {
195208
runtimeOnly(libs.hsqldb)
196209
}
197210
}
211+
212+
// The servlet app with Log4j2 instead of Logback, so Sentry's auto-configuration attaches its
213+
// Log4j2 appender.
214+
register<JvmTestSuite>("integrationTestLog4j2") {
215+
useJUnit()
216+
dependencies {
217+
implementation(platform(SpringBootPlugin.BOM_COORDINATES))
218+
implementation(projects.sentrySpringBoot4Starter)
219+
implementation(projects.sentryLog4j2)
220+
implementation(projects.sentryTestSupport)
221+
implementation(libs.kotlin.test.junit)
222+
implementation(libs.google.truth)
223+
implementation("org.springframework.boot:spring-boot-starter-log4j2")
224+
implementation(libs.springboot4.resttestclient)
225+
implementation(libs.springboot4.starter.restclient)
226+
implementation(libs.springboot4.starter.test)
227+
implementation(libs.springboot4.starter.web)
228+
}
229+
}
198230
}
199231
}
200232

201233
// The Spring Boot Gradle plugin compiles apps with parameter names, which Spring reads, for example
202234
// to resolve `#id` in a cache key.
203235
tasks.named<KotlinCompile>("compileIntegrationTestKotlin") { compilerOptions.javaParameters = true }
204236

205-
tasks.named("check") { dependsOn(testing.suites.named("integrationTest")) }
237+
tasks.named("check") {
238+
dependsOn(
239+
testing.suites.named("integrationTest"),
240+
testing.suites.named("integrationTestLog4j2"),
241+
)
242+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.sentry.spring.boot4.log4j2integrationtest
2+
3+
import io.sentry.Sentry
4+
import org.slf4j.LoggerFactory
5+
import org.springframework.boot.autoconfigure.SpringBootApplication
6+
import org.springframework.web.bind.annotation.GetMapping
7+
import org.springframework.web.bind.annotation.PathVariable
8+
import org.springframework.web.bind.annotation.RequestMapping
9+
import org.springframework.web.bind.annotation.RestController
10+
11+
@SpringBootApplication class IntegrationTestApplication
12+
13+
@RestController
14+
@RequestMapping("/person/")
15+
class PersonController {
16+
private val logger = LoggerFactory.getLogger(PersonController::class.java)
17+
18+
/** Logs an error through SLF4J, which Log4j2 handles in this app, then fails the request. */
19+
@GetMapping("{id}")
20+
fun person(@PathVariable("id") id: Long): String {
21+
Sentry.addFeatureFlag("my-feature-flag", true)
22+
logger.error("Trying person with id={}", id, RuntimeException("error while loading"))
23+
throw IllegalArgumentException("Something went wrong [id=$id]")
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package io.sentry.spring.boot4.log4j2integrationtest
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import io.sentry.Sentry
5+
import io.sentry.test.MockSentryServer
6+
import io.sentry.test.event
7+
import io.sentry.test.hasFlag
8+
import kotlin.test.Test
9+
import org.junit.runner.RunWith
10+
import org.springframework.boot.restclient.RestTemplateBuilder
11+
import org.springframework.boot.resttestclient.TestRestTemplate
12+
import org.springframework.boot.test.context.SpringBootTest
13+
import org.springframework.boot.test.web.server.LocalServerPort
14+
import org.springframework.test.context.DynamicPropertyRegistry
15+
import org.springframework.test.context.DynamicPropertySource
16+
import org.springframework.test.context.junit4.SpringRunner
17+
18+
/**
19+
* Runs [IntegrationTestApplication] with Log4j2 instead of Logback, so Sentry's auto-configuration
20+
* attaches its Log4j2 appender, and asserts what reaches [server].
21+
*/
22+
@RunWith(SpringRunner::class)
23+
@SpringBootTest(
24+
classes = [IntegrationTestApplication::class],
25+
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
26+
// Sentry only attaches its Log4j2 appender when asked to.
27+
properties = ["sentry.traces-sample-rate=1.0", "sentry.logging.enabled=true"],
28+
)
29+
class Log4j2IntegrationTest {
30+
@LocalServerPort private var port = 0
31+
32+
// The context's TestRestTemplate comes from the RestTemplateBuilder that Sentry customizes, so
33+
// it would add trace headers of its own to the requests tests send. Tests use a plain one.
34+
private val restTemplate: TestRestTemplate by lazy {
35+
TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port"))
36+
}
37+
38+
@Test
39+
fun `error logged through Log4j2 reaches Sentry with the request's flag`() {
40+
val response = restTemplate.getForEntity("/person/1", String::class.java)
41+
assertThat(response.statusCode.value()).isEqualTo(500)
42+
Sentry.flush(5000)
43+
44+
val logged =
45+
server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!!
46+
assertThat(logged.hasFlag("my-feature-flag", true)).isTrue()
47+
assertThat(logged.sdk!!.integrationSet).contains("Log4j")
48+
49+
val thrown =
50+
server
51+
.awaitEnvelope {
52+
it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true
53+
}
54+
.event()!!
55+
assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue()
56+
}
57+
58+
companion object {
59+
val server = MockSentryServer()
60+
61+
@JvmStatic
62+
@DynamicPropertySource
63+
fun sentryDsn(registry: DynamicPropertyRegistry) {
64+
registry.add("sentry.dsn") { server.dsn }
65+
}
66+
}
67+
}

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,19 @@ dependencies {
168168
otelAgentJar(projects.sentryOpentelemetry.sentryOpentelemetryAgent)
169169
}
170170

171+
// Swaps Logback for Log4j2 where a classpath has both starters, which only
172+
// integrationTestLog4j2's has.
173+
dependencies {
174+
modules {
175+
module("org.springframework.boot:spring-boot-starter-logging") {
176+
replacedBy(
177+
"org.springframework.boot:spring-boot-starter-log4j2",
178+
"Use Log4j2 instead of Logback",
179+
)
180+
}
181+
}
182+
}
183+
171184
testing {
172185
suites {
173186
// Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own
@@ -207,6 +220,23 @@ testing {
207220
}
208221
}
209222

223+
// The servlet app with Log4j2 instead of Logback, so Sentry's auto-configuration attaches its
224+
// Log4j2 appender.
225+
register<JvmTestSuite>("integrationTestLog4j2") {
226+
useJUnit()
227+
dependencies {
228+
implementation(platform(SpringBootPlugin.BOM_COORDINATES))
229+
implementation(projects.sentrySpringBootStarterJakarta)
230+
implementation(projects.sentryLog4j2)
231+
implementation(projects.sentryTestSupport)
232+
implementation(libs.kotlin.test.junit)
233+
implementation(libs.google.truth)
234+
implementation("org.springframework.boot:spring-boot-starter-log4j2")
235+
implementation(libs.springboot3.starter.test)
236+
implementation(libs.springboot3.starter.web)
237+
}
238+
}
239+
210240
register<JvmTestSuite>("integrationTestOtelAgent") {
211241
useJUnit()
212242
dependencies {
@@ -235,6 +265,7 @@ tasks.named<KotlinCompile>("compileIntegrationTestKotlin") { compilerOptions.jav
235265
tasks.named("check") {
236266
dependsOn(
237267
testing.suites.named("integrationTest"),
268+
testing.suites.named("integrationTestLog4j2"),
238269
testing.suites.named("integrationTestOtelAgent"),
239270
)
240271
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package io.sentry.spring.boot.jakarta.log4j2integrationtest
2+
3+
import io.sentry.Sentry
4+
import org.slf4j.LoggerFactory
5+
import org.springframework.boot.autoconfigure.SpringBootApplication
6+
import org.springframework.web.bind.annotation.GetMapping
7+
import org.springframework.web.bind.annotation.PathVariable
8+
import org.springframework.web.bind.annotation.RequestMapping
9+
import org.springframework.web.bind.annotation.RestController
10+
11+
// Classes and methods are open because Spring proxies them and this module doesn't apply the
12+
// kotlin-spring plugin.
13+
14+
@SpringBootApplication open class IntegrationTestApplication
15+
16+
@RestController
17+
@RequestMapping("/person/")
18+
open class PersonController {
19+
private val logger = LoggerFactory.getLogger(PersonController::class.java)
20+
21+
/** Logs an error through SLF4J, which Log4j2 handles in this app, then fails the request. */
22+
@GetMapping("{id}")
23+
open fun person(@PathVariable("id") id: Long): String {
24+
Sentry.addFeatureFlag("my-feature-flag", true)
25+
logger.error("Trying person with id={}", id, RuntimeException("error while loading"))
26+
throw IllegalArgumentException("Something went wrong [id=$id]")
27+
}
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package io.sentry.spring.boot.jakarta.log4j2integrationtest
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import io.sentry.Sentry
5+
import io.sentry.test.MockSentryServer
6+
import io.sentry.test.event
7+
import io.sentry.test.hasFlag
8+
import kotlin.test.Test
9+
import org.junit.runner.RunWith
10+
import org.springframework.boot.test.context.SpringBootTest
11+
import org.springframework.boot.test.web.client.TestRestTemplate
12+
import org.springframework.boot.test.web.server.LocalServerPort
13+
import org.springframework.boot.web.client.RestTemplateBuilder
14+
import org.springframework.test.context.DynamicPropertyRegistry
15+
import org.springframework.test.context.DynamicPropertySource
16+
import org.springframework.test.context.junit4.SpringRunner
17+
18+
/**
19+
* Runs [IntegrationTestApplication] with Log4j2 instead of Logback, so Sentry's auto-configuration
20+
* attaches its Log4j2 appender, and asserts what reaches [server].
21+
*/
22+
@RunWith(SpringRunner::class)
23+
@SpringBootTest(
24+
classes = [IntegrationTestApplication::class],
25+
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
26+
// Sentry only attaches its Log4j2 appender when asked to.
27+
properties = ["sentry.traces-sample-rate=1.0", "sentry.logging.enabled=true"],
28+
)
29+
class Log4j2IntegrationTest {
30+
@LocalServerPort private var port = 0
31+
32+
// The context's TestRestTemplate comes from the RestTemplateBuilder that Sentry customizes, so
33+
// it would add trace headers of its own to the requests tests send. Tests use a plain one.
34+
private val restTemplate: TestRestTemplate by lazy {
35+
TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port"))
36+
}
37+
38+
@Test
39+
fun `error logged through Log4j2 reaches Sentry with the request's flag`() {
40+
val response = restTemplate.getForEntity("/person/1", String::class.java)
41+
assertThat(response.statusCode.value()).isEqualTo(500)
42+
Sentry.flush(5000)
43+
44+
val logged =
45+
server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!!
46+
assertThat(logged.hasFlag("my-feature-flag", true)).isTrue()
47+
assertThat(logged.sdk!!.integrationSet).contains("Log4j")
48+
49+
val thrown =
50+
server
51+
.awaitEnvelope {
52+
it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true
53+
}
54+
.event()!!
55+
assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue()
56+
}
57+
58+
companion object {
59+
val server = MockSentryServer()
60+
61+
@JvmStatic
62+
@DynamicPropertySource
63+
fun sentryDsn(registry: DynamicPropertyRegistry) {
64+
registry.add("sentry.dsn") { server.dsn }
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)