Skip to content

Commit 57716e0

Browse files
committed
test(spring-boot): Move the Boot 3 cache, metrics and feature flag tests into the module
Ports the jakarta sample's CacheSystemTest, MetricsSystemTest and FeatureFlagSystemTest to the integrationTest suite. Feature flags use the OpenFeature SDK's InMemoryProvider instead of the sample's hand-written provider. The suite's Kotlin is compiled with parameter names, as the Spring Boot Gradle plugin does for apps; without them Spring can't resolve `#id` in a cache key. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent f76055f commit 57716e0

12 files changed

Lines changed: 317 additions & 204 deletions

File tree

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

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

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

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

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

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

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,19 @@ testing {
181181
implementation(projects.sentryJdbc)
182182
implementation(projects.sentryLogback)
183183
implementation(projects.sentryOkhttp)
184+
implementation(projects.sentryOpenfeature)
184185
implementation(projects.sentryTestSupport)
185186
// Reactor's automatic context propagation needs it to carry the Sentry scope across
186187
// threads.
187188
// The sample only had it because Spring GraphQL pulls it in.
188189
implementation(libs.context.propagation)
189190
implementation(libs.kotlin.test.junit)
190191
implementation(libs.google.truth)
192+
implementation(libs.caffeine)
191193
implementation(libs.okhttp)
194+
implementation(libs.openfeature)
192195
implementation(libs.springboot3.starter.aop)
196+
implementation(libs.springboot3.starter.cache)
193197
implementation(libs.springboot3.starter.jdbc)
194198
implementation(libs.springboot3.starter.test)
195199
implementation(libs.springboot3.starter.web)
@@ -218,3 +222,7 @@ testing {
218222
}
219223
}
220224
}
225+
226+
// The Spring Boot Gradle plugin compiles apps with parameter names, which Spring reads, for example
227+
// to resolve `#id` in a cache key.
228+
tasks.named<KotlinCompile>("compileIntegrationTestKotlin") { compilerOptions.javaParameters = true }
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.sentry.spring.boot.jakarta.integrationtest
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+
open class CacheController(private val todoCache: TodoCache) {
19+
@GetMapping("{id}") open fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id)
20+
21+
@PostMapping open fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo)
22+
23+
@DeleteMapping("{id}") open 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+
open class TodoCache {
29+
private val store = ConcurrentHashMap<Long, Todo>()
30+
31+
@Cacheable(value = ["todos"], key = "#id") open fun get(id: Long): Todo? = store[id]
32+
33+
@CachePut(value = ["todos"], key = "#todo.id")
34+
open fun save(todo: Todo): Todo {
35+
store[todo.id!!] = todo
36+
return todo
37+
}
38+
39+
@CacheEvict(value = ["todos"], key = "#id")
40+
open 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.boot.jakarta.integrationtest
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 CacheIntegrationTest : IntegrationTest() {
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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package io.sentry.spring.boot.jakarta.integrationtest
2+
3+
import dev.openfeature.sdk.OpenFeatureAPI
4+
import dev.openfeature.sdk.providers.memory.Flag
5+
import dev.openfeature.sdk.providers.memory.InMemoryProvider
6+
import io.sentry.openfeature.SentryOpenFeatureHook
7+
import org.springframework.context.annotation.Bean
8+
import org.springframework.context.annotation.Configuration
9+
import org.springframework.web.bind.annotation.GetMapping
10+
import org.springframework.web.bind.annotation.PathVariable
11+
import org.springframework.web.bind.annotation.RequestMapping
12+
import org.springframework.web.bind.annotation.RestController
13+
14+
open class FeatureFlagResponse(var flagKey: String? = null, var value: Boolean = false)
15+
16+
@RestController
17+
@RequestMapping("/feature-flag/")
18+
open class FeatureFlagController(private val openFeature: OpenFeatureAPI) {
19+
@GetMapping("check/{flagKey}")
20+
open fun check(@PathVariable("flagKey") flagKey: String): FeatureFlagResponse =
21+
FeatureFlagResponse(flagKey, evaluate(flagKey))
22+
23+
@GetMapping("error/{flagKey}")
24+
open fun error(@PathVariable("flagKey") flagKey: String): String {
25+
val value = evaluate(flagKey)
26+
throw RuntimeException("Error occurred with feature flag: $flagKey = $value")
27+
}
28+
29+
private fun evaluate(flagKey: String): Boolean =
30+
openFeature.client.getBooleanValue(flagKey, false)
31+
}
32+
33+
@Configuration
34+
open class OpenFeatureConfig {
35+
/** Evaluations go through [SentryOpenFeatureHook], which records them on the current scope. */
36+
@Bean
37+
open fun openFeatureAPI(): OpenFeatureAPI =
38+
OpenFeatureAPI.getInstance().apply {
39+
setProviderAndWait(
40+
InMemoryProvider(
41+
mapOf(
42+
"new-checkout-flow" to booleanFlag(true),
43+
"experimental-feature" to booleanFlag(false),
44+
"beta-features" to booleanFlag(true),
45+
)
46+
)
47+
)
48+
addHooks(SentryOpenFeatureHook())
49+
}
50+
51+
private fun booleanFlag(value: Boolean): Flag<Boolean> =
52+
Flag.builder<Boolean>().variant("value", value).defaultVariant("value").build()
53+
}

0 commit comments

Comments
 (0)