Skip to content

Commit 3aa588c

Browse files
committed
test: Port the audit fixes to Boot 4 WebFlux
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 393829a commit 3aa588c

7 files changed

Lines changed: 190 additions & 11 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.sentry.spring.boot4.webfluxintegrationtest
2+
3+
import java.util.concurrent.ConcurrentHashMap
4+
import org.springframework.cache.annotation.CacheEvict
5+
import org.springframework.cache.annotation.CachePut
6+
import org.springframework.cache.annotation.Cacheable
7+
import org.springframework.stereotype.Service
8+
import org.springframework.web.bind.annotation.DeleteMapping
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+
@RestController
17+
@RequestMapping("/cache/")
18+
class CacheController(private val todoCache: TodoCache) {
19+
@GetMapping("{id}") fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id)
20+
21+
@PostMapping fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo)
22+
23+
@DeleteMapping("{id}") fun delete(@PathVariable("id") id: Long) = todoCache.delete(id)
24+
}
25+
26+
/** Reads and writes todos through Spring's cache abstraction, which Sentry traces. */
27+
@Service
28+
class TodoCache {
29+
private val store = ConcurrentHashMap<Long, Todo>()
30+
31+
@Cacheable(value = ["todos"], key = "#id") fun get(id: Long): Todo? = store[id]
32+
33+
@CachePut(value = ["todos"], key = "#todo.id")
34+
fun save(todo: Todo): Todo {
35+
store[todo.id!!] = todo
36+
return todo
37+
}
38+
39+
@CacheEvict(value = ["todos"], key = "#id")
40+
fun delete(id: Long) {
41+
store.remove(id)
42+
}
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package io.sentry.spring.boot4.webfluxintegrationtest
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import io.sentry.test.hasSpanWithOp
5+
import io.sentry.test.transaction
6+
import kotlin.test.Test
7+
import org.springframework.http.HttpEntity
8+
import org.springframework.http.HttpMethod
9+
10+
class CacheWebfluxIntegrationTest : WebfluxIntegrationTest() {
11+
12+
@Test
13+
fun `cache put and get produce spans`() {
14+
val saved = restTemplate.postForEntity("/cache/", Todo(1L, "test-todo"), Todo::class.java)
15+
assertThat(saved.statusCode.value()).isEqualTo(200)
16+
assertThat(awaitTransaction("POST /cache/").hasSpanWithOp("cache.put")).isTrue()
17+
18+
val fetched = restTemplate.getForEntity("/cache/1", Todo::class.java)
19+
assertThat(fetched.statusCode.value()).isEqualTo(200)
20+
assertThat(fetched.body!!.title).isEqualTo("test-todo")
21+
assertThat(awaitTransaction("GET /cache/{id}").hasSpanWithOp("cache.get")).isTrue()
22+
}
23+
24+
@Test
25+
fun `cache evict produces span`() {
26+
val response =
27+
restTemplate.exchange("/cache/1", HttpMethod.DELETE, HttpEntity.EMPTY, Void::class.java)
28+
assertThat(response.statusCode.value()).isEqualTo(200)
29+
30+
assertThat(awaitTransaction("DELETE /cache/{id}").hasSpanWithOp("cache.evict")).isTrue()
31+
}
32+
33+
private fun awaitTransaction(name: String) =
34+
server.awaitEnvelope { it.transaction()?.transaction == name }.transaction()!!
35+
}

‎sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/DistributedTracingWebfluxIntegrationTest.kt‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat
44
import io.sentry.SentryEnvelope
55
import io.sentry.protocol.SentryId
66
import io.sentry.test.transaction
7+
import kotlin.random.Random
78
import kotlin.test.Test
89
import org.springframework.http.HttpEntity
910
import org.springframework.http.HttpHeaders
@@ -27,26 +28,26 @@ class DistributedTracingWebfluxIntegrationTest : WebfluxIntegrationTest() {
2728
)
2829
assertThat(status).isEqualTo(500)
2930

30-
awaitTransaction("GET /tracing/{id}", traceId)
31+
val incoming = awaitTransaction("GET /tracing/{id}", traceId).transaction()!!
32+
assertThat(incoming.request?.url).endsWith("/tracing/1")
3133
awaitTransaction("GET /person/{id}", traceId)
3234
}
3335

3436
@Test
3537
fun `unsampled trace sends no transactions`() {
3638
val traceId = SentryId()
39+
// An id only this request uses, because a mishandled header could start a trace of its own.
40+
val id = Random.nextLong(2, Long.MAX_VALUE)
3741
val status =
3842
get(
39-
"/tracing/1",
43+
"/tracing/$id",
4044
sentryTrace = "$traceId-424cffc8f94feeee-0",
4145
baggage =
4246
"sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET",
4347
)
4448
assertThat(status).isEqualTo(500)
4549

46-
server.assertNoEnvelope {
47-
isTransaction(it, "GET /tracing/{id}", traceId) ||
48-
isTransaction(it, "GET /person/{id}", traceId)
49-
}
50+
server.assertNoEnvelope { isForRequest(it, id) }
5051
}
5152

5253
@Test
@@ -63,6 +64,8 @@ class DistributedTracingWebfluxIntegrationTest : WebfluxIntegrationTest() {
6364

6465
val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!!
6566
val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!!
67+
assertThat(incoming.traceId).isEqualTo(traceId)
68+
assertThat(outgoing.traceId).isEqualTo(traceId)
6669
assertThat(incoming.sampleRand).isNotNull()
6770
assertThat(outgoing.sampleRand).isEqualTo(incoming.sampleRand)
6871
}
@@ -81,6 +84,8 @@ class DistributedTracingWebfluxIntegrationTest : WebfluxIntegrationTest() {
8184

8285
val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!!
8386
val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!!
87+
assertThat(incoming.traceId).isEqualTo(traceId)
88+
assertThat(outgoing.traceId).isEqualTo(traceId)
8489
assertThat(incoming.sampleRate).isNotEqualTo("0.5")
8590
assertThat(outgoing.sampleRate).isEqualTo(incoming.sampleRate)
8691
}
@@ -130,6 +135,12 @@ class DistributedTracingWebfluxIntegrationTest : WebfluxIntegrationTest() {
130135
isTransaction(it, name, traceId)
131136
}
132137

138+
/** Whether [envelope] is a transaction for `/tracing/[id]` or the `/person/[id]` it calls. */
139+
private fun isForRequest(envelope: SentryEnvelope, id: Long): Boolean {
140+
val url = envelope.transaction()?.request?.url ?: return false
141+
return url.endsWith("/tracing/$id") || url.endsWith("/person/$id")
142+
}
143+
133144
private fun isTransaction(envelope: SentryEnvelope, name: String, traceId: SentryId): Boolean {
134145
val transaction = envelope.transaction() ?: return false
135146
return transaction.transaction == name && transaction.contexts.trace?.traceId == traceId
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package io.sentry.spring.boot4.webfluxintegrationtest
2+
3+
import io.sentry.Sentry
4+
import io.sentry.metrics.MetricsUnit
5+
import org.springframework.web.bind.annotation.GetMapping
6+
import org.springframework.web.bind.annotation.PathVariable
7+
import org.springframework.web.bind.annotation.RequestMapping
8+
import org.springframework.web.bind.annotation.RestController
9+
10+
@RestController
11+
@RequestMapping("/metric/")
12+
class MetricController {
13+
@GetMapping("count")
14+
fun count(): String {
15+
Sentry.setAttribute("user.type", "admin")
16+
Sentry.setAttribute("feature.version", 2)
17+
Sentry.metrics().count("countMetric")
18+
return "count metric increased"
19+
}
20+
21+
@GetMapping("gauge/{value}")
22+
fun gauge(@PathVariable("value") value: Long): String {
23+
Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE)
24+
return "gauge metric tracked"
25+
}
26+
27+
@GetMapping("distribution/{value}")
28+
fun distribution(@PathVariable("value") value: Long): String {
29+
Sentry.metrics()
30+
.distribution("distributionMetric", value.toDouble(), MetricsUnit.Duration.MILLISECOND)
31+
return "distribution metric tracked"
32+
}
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package io.sentry.spring.boot4.webfluxintegrationtest
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import io.sentry.Sentry
5+
import io.sentry.SentryMetricsEvents
6+
import io.sentry.test.hasMetric
7+
import io.sentry.test.hasMetricWithAttribute
8+
import io.sentry.test.metrics
9+
import kotlin.test.Test
10+
11+
class MetricsWebfluxIntegrationTest : WebfluxIntegrationTest() {
12+
13+
@Test
14+
fun `count metric carries the scope's attributes`() {
15+
assertThat(restTemplate.getForObject("/metric/count", String::class.java))
16+
.isEqualTo("count metric increased")
17+
18+
val metrics = awaitMetric("countMetric")
19+
assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue()
20+
assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue()
21+
assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue()
22+
}
23+
24+
@Test
25+
fun `gauge metric`() {
26+
assertThat(restTemplate.getForObject("/metric/gauge/14", String::class.java))
27+
.isEqualTo("gauge metric tracked")
28+
29+
assertThat(awaitMetric("memory.free").hasMetric("memory.free", "gauge", 14.0)).isTrue()
30+
}
31+
32+
@Test
33+
fun `distribution metric`() {
34+
assertThat(restTemplate.getForObject("/metric/distribution/23", String::class.java))
35+
.isEqualTo("distribution metric tracked")
36+
37+
assertThat(
38+
awaitMetric("distributionMetric").hasMetric("distributionMetric", "distribution", 23.0)
39+
)
40+
.isTrue()
41+
}
42+
43+
/** Flushes first because metrics are batched before they're sent. */
44+
private fun awaitMetric(name: String): SentryMetricsEvents {
45+
Sentry.flush(5000)
46+
return server
47+
.awaitEnvelope { it.metrics()?.items?.any { metric -> metric.name == name } == true }
48+
.metrics()!!
49+
}
50+
}

‎sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTest.kt‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ package io.sentry.spring.boot4.webfluxintegrationtest
33
import io.sentry.test.MockSentryServer
44
import org.junit.Before
55
import org.junit.runner.RunWith
6-
import org.springframework.beans.factory.annotation.Autowired
6+
import org.springframework.boot.restclient.RestTemplateBuilder
77
import org.springframework.boot.resttestclient.TestRestTemplate
8-
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate
98
import org.springframework.boot.test.context.SpringBootTest
9+
import org.springframework.boot.test.web.server.LocalServerPort
1010
import org.springframework.test.context.DynamicPropertyRegistry
1111
import org.springframework.test.context.DynamicPropertySource
1212
import org.springframework.test.context.junit4.SpringRunner
@@ -20,14 +20,13 @@ import org.springframework.test.context.junit4.SpringRunner
2020
* produce.
2121
*/
2222
@RunWith(SpringRunner::class)
23-
// Spring Boot 4 only provides TestRestTemplate when asked to.
24-
@AutoConfigureTestRestTemplate
2523
@SpringBootTest(
2624
classes = [WebfluxIntegrationTestApplication::class],
2725
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
2826
properties =
2927
[
3028
"sentry.traces-sample-rate=1.0",
29+
"sentry.enable-cache-tracing=true",
3130
"sentry.logs.enabled=true",
3231
"sentry.reactive.thread-local-accessor-enabled=true",
3332
"sentry.profile-session-sample-rate=1.0",
@@ -36,7 +35,13 @@ import org.springframework.test.context.junit4.SpringRunner
3635
],
3736
)
3837
abstract class WebfluxIntegrationTest {
39-
@Autowired lateinit var restTemplate: TestRestTemplate
38+
@LocalServerPort private var port = 0
39+
40+
// The context's TestRestTemplate comes from the RestTemplateBuilder that Sentry customizes, so
41+
// it would add trace headers of its own to the requests tests send. Tests use a plain one.
42+
val restTemplate: TestRestTemplate by lazy {
43+
TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port"))
44+
}
4045

4146
@Before
4247
fun resetServer() {

‎sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTestApplication.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io.sentry.spring.boot4.webfluxintegrationtest
33
import io.sentry.Sentry
44
import java.time.Duration
55
import org.springframework.boot.autoconfigure.SpringBootApplication
6+
import org.springframework.cache.annotation.EnableCaching
67
import org.springframework.context.annotation.Bean
78
import org.springframework.graphql.data.method.annotation.Argument
89
import org.springframework.graphql.data.method.annotation.QueryMapping
@@ -20,6 +21,7 @@ import reactor.core.publisher.Mono
2021
import reactor.core.scheduler.Schedulers
2122

2223
@SpringBootApplication
24+
@EnableCaching
2325
class WebfluxIntegrationTestApplication {
2426
// Built from Spring Boot's builder, which Sentry customizes to trace outgoing requests.
2527
@Bean fun webClient(builder: WebClient.Builder): WebClient = builder.build()

0 commit comments

Comments
 (0)