Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

This file was deleted.

This file was deleted.

8 changes: 8 additions & 0 deletions sentry-spring-boot-jakarta/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,19 @@ testing {
implementation(projects.sentryJdbc)
implementation(projects.sentryLogback)
implementation(projects.sentryOkhttp)
implementation(projects.sentryOpenfeature)
implementation(projects.sentryTestSupport)
// Reactor's automatic context propagation needs it to carry the Sentry scope across
// threads.
// The sample only had it because Spring GraphQL pulls it in.
implementation(libs.context.propagation)
implementation(libs.kotlin.test.junit)
implementation(libs.google.truth)
implementation(libs.caffeine)
implementation(libs.okhttp)
implementation(libs.openfeature)
implementation(libs.springboot3.starter.aop)
implementation(libs.springboot3.starter.cache)
implementation(libs.springboot3.starter.jdbc)
implementation(libs.springboot3.starter.test)
implementation(libs.springboot3.starter.web)
Expand Down Expand Up @@ -219,6 +223,10 @@ testing {
}
}

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

tasks.named("check") {
dependsOn(
testing.suites.named("integrationTest"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.sentry.spring.boot.jakarta.integrationtest

import java.util.concurrent.ConcurrentHashMap
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.CachePut
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/cache/")
open class CacheController(private val todoCache: TodoCache) {
@GetMapping("{id}") open fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id)

@PostMapping open fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo)

@DeleteMapping("{id}") open fun delete(@PathVariable("id") id: Long) = todoCache.delete(id)
}

/** Reads and writes todos through Spring's cache abstraction, which Sentry traces. */
@Service
open class TodoCache {
private val store = ConcurrentHashMap<Long, Todo>()

@Cacheable(value = ["todos"], key = "#id") open fun get(id: Long): Todo? = store[id]

@CachePut(value = ["todos"], key = "#todo.id")
open fun save(todo: Todo): Todo {
store[todo.id!!] = todo
return todo
}

@CacheEvict(value = ["todos"], key = "#id")
open fun delete(id: Long) {
store.remove(id)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.sentry.spring.boot.jakarta.integrationtest

import com.google.common.truth.Truth.assertThat
import io.sentry.test.hasSpanWithOp
import io.sentry.test.transaction
import kotlin.test.Test
import org.springframework.http.HttpEntity
import org.springframework.http.HttpMethod

class CacheIntegrationTest : IntegrationTest() {

@Test
fun `cache put and get produce spans`() {
val saved = restTemplate.postForEntity("/cache/", Todo(1L, "test-todo"), Todo::class.java)
assertThat(saved.statusCode.value()).isEqualTo(200)
assertThat(awaitTransaction("POST /cache/").hasSpanWithOp("cache.put")).isTrue()

val fetched = restTemplate.getForEntity("/cache/1", Todo::class.java)
assertThat(fetched.statusCode.value()).isEqualTo(200)
assertThat(fetched.body!!.title).isEqualTo("test-todo")
assertThat(awaitTransaction("GET /cache/{id}").hasSpanWithOp("cache.get")).isTrue()
}

@Test
fun `cache evict produces span`() {
val response =
restTemplate.exchange("/cache/1", HttpMethod.DELETE, HttpEntity.EMPTY, Void::class.java)
assertThat(response.statusCode.value()).isEqualTo(200)

assertThat(awaitTransaction("DELETE /cache/{id}").hasSpanWithOp("cache.evict")).isTrue()
}

private fun awaitTransaction(name: String) =
server.awaitEnvelope { it.transaction()?.transaction == name }.transaction()!!
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.sentry.spring.boot.jakarta.integrationtest

import dev.openfeature.sdk.OpenFeatureAPI
import dev.openfeature.sdk.providers.memory.Flag
import dev.openfeature.sdk.providers.memory.InMemoryProvider
import io.sentry.openfeature.SentryOpenFeatureHook
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

open class FeatureFlagResponse(var flagKey: String? = null, var value: Boolean = false)

@RestController
@RequestMapping("/feature-flag/")
open class FeatureFlagController(private val openFeature: OpenFeatureAPI) {
@GetMapping("check/{flagKey}")
open fun check(@PathVariable("flagKey") flagKey: String): FeatureFlagResponse =
FeatureFlagResponse(flagKey, evaluate(flagKey))

@GetMapping("error/{flagKey}")
open fun error(@PathVariable("flagKey") flagKey: String): String {
val value = evaluate(flagKey)
throw RuntimeException("Error occurred with feature flag: $flagKey = $value")
}

private fun evaluate(flagKey: String): Boolean =
openFeature.client.getBooleanValue(flagKey, false)
}

@Configuration
open class OpenFeatureConfig {
/** Evaluations go through [SentryOpenFeatureHook], which records them on the current scope. */
@Bean
open fun openFeatureAPI(): OpenFeatureAPI =
OpenFeatureAPI.getInstance().apply {
setProviderAndWait(
InMemoryProvider(
mapOf(
"new-checkout-flow" to booleanFlag(true),
"experimental-feature" to booleanFlag(false),
"beta-features" to booleanFlag(true),
)
)
)
addHooks(SentryOpenFeatureHook())
}

private fun booleanFlag(value: Boolean): Flag<Boolean> =
Flag.builder<Boolean>().variant("value", value).defaultVariant("value").build()
}
Loading
Loading