From c59a4939a6a4db7e3621fc910c8cd93c42de7733 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 20 Oct 2026 15:33:58 +0200 Subject: [PATCH] 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) --- .../io/sentry/systemtest/CacheSystemTest.kt | 51 --------- .../systemtest/FeatureFlagSystemTest.kt | 102 ------------------ .../io/sentry/systemtest/MetricsSystemTest.kt | 51 --------- sentry-spring-boot-jakarta/build.gradle.kts | 8 ++ .../integrationtest/CacheController.kt | 43 ++++++++ .../integrationtest/CacheIntegrationTest.kt | 35 ++++++ .../integrationtest/FeatureFlagController.kt | 53 +++++++++ .../FeatureFlagIntegrationTest.kt | 92 ++++++++++++++++ .../integrationtest/IntegrationTest.kt | 1 + .../IntegrationTestApplication.kt | 2 + .../integrationtest/MetricController.kt | 33 ++++++ .../integrationtest/MetricsIntegrationTest.kt | 50 +++++++++ 12 files changed, 317 insertions(+), 204 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagIntegrationTest.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricController.kt create mode 100644 sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricsIntegrationTest.kt diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c10853..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class CacheSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `cache put and get produce spans`() { - val restClient = testHelper.restClient - - // Save a todo (triggers @CachePut -> cache.put span) - val todo = Todo(1L, "test-todo", false) - restClient.saveCachedTodo(todo) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.put") - } - - testHelper.reset() - - // Get the todo (triggers @Cacheable -> cache.get span, should be a hit) - restClient.getCachedTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.get") - } - } - - @Test - fun `cache evict produces span`() { - val restClient = testHelper.restClient - - restClient.deleteCachedTodo(1L) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.evict") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt deleted file mode 100644 index 10233e579e5..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt +++ /dev/null @@ -1,102 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.FeatureFlag -import io.sentry.systemtest.util.FeatureFlagResponse -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import org.junit.Before - -class FeatureFlagSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `check feature flag includes flag in transaction`() { - val restClient = testHelper.restClient - val response: FeatureFlagResponse? = restClient.checkFeatureFlag("new-checkout-flow") - - assertEquals(200, restClient.lastKnownStatusCode) - assertNotNull(response) - assertEquals("new-checkout-flow", response!!.flagKey) - assertEquals(true, response.value) - - // Verify feature flag is included in the transaction - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /feature-flag/check/{flagKey}" && - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.new-checkout-flow", true), - ) - } - } - - @Test - fun `error with feature flag includes flag in error event and transaction`() { - val restClient = testHelper.restClient - restClient.errorWithFeatureFlag("beta-features") - assertEquals(500, restClient.lastKnownStatusCode) - - // Verify feature flag is included in the error event - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage( - event, - "Error occurred with feature flag: beta-features = true", - ) && testHelper.doesEventHaveFlag(event, "beta-features", true) - } - - // Verify feature flag is also included in the transaction - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /feature-flag/error/{flagKey}" && - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.beta-features", true), - ) - } - } - - @Test - fun `check non-existent feature flag returns default value`() { - val restClient = testHelper.restClient - val response: FeatureFlagResponse? = restClient.checkFeatureFlag("non-existent-flag") - - assertEquals(200, restClient.lastKnownStatusCode) - assertNotNull(response) - assertEquals("non-existent-flag", response!!.flagKey) - assertEquals(false, response.value) // Default value - - // Verify transaction is still created - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /feature-flag/check/{flagKey}" - } - } - - @Test - fun `check feature flag with false value`() { - val restClient = testHelper.restClient - val response: FeatureFlagResponse? = restClient.checkFeatureFlag("experimental-feature") - - assertEquals(200, restClient.lastKnownStatusCode) - assertNotNull(response) - assertEquals("experimental-feature", response!!.flagKey) - assertEquals(false, response.value) - - // Verify feature flag with false value is included in transaction - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /feature-flag/check/{flagKey}" && - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.experimental-feature", false), - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 039d9d640c7..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class MetricsSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `count metric`() { - val restClient = testHelper.restClient - assertEquals("count metric increased", restClient.getCountMetric()) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) && - testHelper.doesMetricHaveAttribute(event, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(event, "countMetric", "feature.version", 2) - } - } - - @Test - fun `gauge metric`() { - val restClient = testHelper.restClient - assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) - } - } - - @Test - fun `distribution metric`() { - val restClient = testHelper.restClient - assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) - } - } -} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 1f9f60533ef..5c00c0747f2 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -181,6 +181,7 @@ 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. @@ -188,8 +189,11 @@ testing { 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) @@ -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("compileIntegrationTestKotlin") { compilerOptions.javaParameters = true } + tasks.named("check") { dependsOn( testing.suites.named("integrationTest"), diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheController.kt new file mode 100644 index 00000000000..3eca5b57128 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheController.kt @@ -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() + + @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) + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheIntegrationTest.kt new file mode 100644 index 00000000000..ee56c352ce1 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/CacheIntegrationTest.kt @@ -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()!! +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagController.kt new file mode 100644 index 00000000000..808d89cb184 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagController.kt @@ -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 = + Flag.builder().variant("value", value).defaultVariant("value").build() +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagIntegrationTest.kt new file mode 100644 index 00000000000..421bad16d3a --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/FeatureFlagIntegrationTest.kt @@ -0,0 +1,92 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.FeatureFlag +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasOp +import io.sentry.test.transaction +import kotlin.test.Test + +/** Feature flags evaluated through OpenFeature end up on the transaction and on errors. */ +class FeatureFlagIntegrationTest : IntegrationTest() { + + @Test + fun `evaluated flag is on the transaction`() { + assertThat(check("new-checkout-flow")).isTrue() + + assertThat( + awaitTransaction("new-checkout-flow") + .hasOp("http.server", FeatureFlag("flag.evaluation.new-checkout-flow", true)) + ) + .isTrue() + } + + @Test + fun `flag evaluated to false is on the transaction`() { + assertThat(check("experimental-feature")).isFalse() + + assertThat( + awaitTransaction("experimental-feature") + .hasOp("http.server", FeatureFlag("flag.evaluation.experimental-feature", false)) + ) + .isTrue() + } + + @Test + fun `unknown flag falls back to its default`() { + assertThat(check("non-existent-flag")).isFalse() + + awaitTransaction("non-existent-flag") + } + + @Test + fun `flag evaluated before an error is on the error and the transaction`() { + val response = + restTemplate.getForEntity("/feature-flag/error/beta-features", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + + val error = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> + e.value == "Error occurred with feature flag: beta-features = true" + } == true + } + .event()!! + assertThat(error.hasFlag("beta-features", true)).isTrue() + + val transaction = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /feature-flag/error/{flagKey}" && + t.contexts.trace?.data?.containsKey("flag.evaluation.beta-features") == true + } == true + } + .transaction()!! + assertThat(transaction.hasOp("http.server", FeatureFlag("flag.evaluation.beta-features", true))) + .isTrue() + } + + private fun check(flagKey: String): Boolean { + val response = + restTemplate.getForEntity("/feature-flag/check/$flagKey", FeatureFlagResponse::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.flagKey).isEqualTo(flagKey) + return response.body!!.value + } + + /** + * Every check request shares a transaction name, so this picks [flagKey]'s by the request URL. + */ + private fun awaitTransaction(flagKey: String) = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /feature-flag/check/{flagKey}" && + t.request?.url?.endsWith("/feature-flag/check/$flagKey") == true + } == true + } + .transaction()!! +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt index aeffe53c788..a430bd5b543 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt @@ -26,6 +26,7 @@ import org.springframework.test.context.junit4.SpringRunner [ "sentry.traces-sample-rate=1.0", "sentry.clients.ok-http-enabled=true", + "sentry.enable-cache-tracing=true", "sentry.logs.enabled=true", "sentry.profile-session-sample-rate=1.0", "sentry.profile-lifecycle=TRACE", diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt index 89d686dead6..50eb64ffc4c 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTestApplication.kt @@ -6,6 +6,7 @@ import okhttp3.OkHttpClient import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.cache.annotation.EnableCaching import org.springframework.context.annotation.Bean import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Service @@ -24,6 +25,7 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder // kotlin-spring plugin. @SpringBootApplication +@EnableCaching open class IntegrationTestApplication { // Built from Spring Boot's builders, which Sentry customizes to trace outgoing requests. @Bean open fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricController.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricController.kt new file mode 100644 index 00000000000..9c52045c74f --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import io.sentry.Sentry +import io.sentry.metrics.MetricsUnit +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 + +@RestController +@RequestMapping("/metric/") +open class MetricController { + @GetMapping("count") + open fun count(): String { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + return "count metric increased" + } + + @GetMapping("gauge/{value}") + open fun gauge(@PathVariable("value") value: Long): String { + Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE) + return "gauge metric tracked" + } + + @GetMapping("distribution/{value}") + open fun distribution(@PathVariable("value") value: Long): String { + Sentry.metrics() + .distribution("distributionMetric", value.toDouble(), MetricsUnit.Duration.MILLISECOND) + return "distribution metric tracked" + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricsIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricsIntegrationTest.kt new file mode 100644 index 00000000000..8feb647f4dd --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/MetricsIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.boot.jakarta.integrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryMetricsEvents +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import kotlin.test.Test + +class MetricsIntegrationTest : IntegrationTest() { + + @Test + fun `count metric carries the scope's attributes`() { + assertThat(restTemplate.getForObject("/metric/count", String::class.java)) + .isEqualTo("count metric increased") + + val metrics = awaitMetric("countMetric") + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + @Test + fun `gauge metric`() { + assertThat(restTemplate.getForObject("/metric/gauge/14", String::class.java)) + .isEqualTo("gauge metric tracked") + + assertThat(awaitMetric("memory.free").hasMetric("memory.free", "gauge", 14.0)).isTrue() + } + + @Test + fun `distribution metric`() { + assertThat(restTemplate.getForObject("/metric/distribution/23", String::class.java)) + .isEqualTo("distribution metric tracked") + + assertThat( + awaitMetric("distributionMetric").hasMetric("distributionMetric", "distribution", 23.0) + ) + .isTrue() + } + + /** Flushes first because metrics are batched before they're sent. */ + private fun awaitMetric(name: String): SentryMetricsEvents { + Sentry.flush(5000) + return server + .awaitEnvelope { it.metrics()?.items?.any { metric -> metric.name == name } == true } + .metrics()!! + } +}