From 4d9f68376767b871f5a3b3f60914dab52b846f3a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 13 Mar 2026 07:03:39 +0100 Subject: [PATCH 1/2] feat(spring-jakarta): Add cache tracing for Spring Boot 3 / Spring 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port cache tracing classes from sentry-spring-7 to sentry-spring-jakarta, covering Spring Boot 3 (Spring Framework 6.x) users. Includes SentryCacheWrapper, SentryCacheManagerWrapper, SentryCacheBeanPostProcessor, and auto-configuration in sentry-spring-boot-jakarta. The retrieve() overrides for CompletableFuture/reactive cache operations are included and safe on Spring 6.0 (where retrieve() doesn't exist on the Cache interface) — they're simply dead code, never called by the framework until Spring 6.1+. Co-Authored-By: Claude --- .../boot/jakarta/SentryAutoConfiguration.java | 15 + .../api/sentry-spring-jakarta.api | 29 ++ .../cache/SentryCacheBeanPostProcessor.java | 29 ++ .../cache/SentryCacheManagerWrapper.java | 37 ++ .../jakarta/cache/SentryCacheWrapper.java | 303 ++++++++++++ .../cache/SentryCacheBeanPostProcessorTest.kt | 44 ++ .../cache/SentryCacheManagerWrapperTest.kt | 61 +++ .../jakarta/cache/SentryCacheWrapperTest.kt | 463 ++++++++++++++++++ 8 files changed, 981 insertions(+) create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessor.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheManagerWrapper.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheWrapper.java create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessorTest.kt create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheManagerWrapperTest.kt create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheWrapperTest.kt diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java index 8663dac8c56..ef57868ad87 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java @@ -25,6 +25,7 @@ import io.sentry.spring.jakarta.SentryWebConfiguration; import io.sentry.spring.jakarta.SpringProfilesEventProcessor; import io.sentry.spring.jakarta.SpringSecuritySentryUserProvider; +import io.sentry.spring.jakarta.cache.SentryCacheBeanPostProcessor; import io.sentry.spring.jakarta.checkin.SentryCheckInAdviceConfiguration; import io.sentry.spring.jakarta.checkin.SentryCheckInPointcutConfiguration; import io.sentry.spring.jakarta.checkin.SentryQuartzConfiguration; @@ -65,6 +66,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.info.GitProperties; import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @@ -231,6 +233,19 @@ static class Graphql22Configuration {} }) static class QuartzConfiguration {} + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(CacheManager.class) + @ConditionalOnProperty(name = "sentry.enable-cache-tracing", havingValue = "true") + @Open + static class SentryCacheConfiguration { + + @Bean + public static @NotNull SentryCacheBeanPostProcessor sentryCacheBeanPostProcessor() { + SentryIntegrationPackageStorage.getInstance().addIntegration("SpringCache"); + return new SentryCacheBeanPostProcessor(); + } + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(ProceedingJoinPoint.class) @ConditionalOnProperty( diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index f28f4153b59..fe634da6f4c 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -104,6 +104,35 @@ public final class io/sentry/spring/jakarta/SpringSecuritySentryUserProvider : i public fun provideUser ()Lio/sentry/protocol/User; } +public final class io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { + public fun ()V + public fun getOrder ()I + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public final class io/sentry/spring/jakarta/cache/SentryCacheManagerWrapper : org/springframework/cache/CacheManager { + public fun (Lorg/springframework/cache/CacheManager;Lio/sentry/IScopes;)V + public fun getCache (Ljava/lang/String;)Lorg/springframework/cache/Cache; + public fun getCacheNames ()Ljava/util/Collection; +} + +public final class io/sentry/spring/jakarta/cache/SentryCacheWrapper : org/springframework/cache/Cache { + public fun (Lorg/springframework/cache/Cache;Lio/sentry/IScopes;)V + public fun clear ()V + public fun evict (Ljava/lang/Object;)V + public fun evictIfPresent (Ljava/lang/Object;)Z + public fun get (Ljava/lang/Object;)Lorg/springframework/cache/Cache$ValueWrapper; + public fun get (Ljava/lang/Object;Ljava/lang/Class;)Ljava/lang/Object; + public fun get (Ljava/lang/Object;Ljava/util/concurrent/Callable;)Ljava/lang/Object; + public fun getName ()Ljava/lang/String; + public fun getNativeCache ()Ljava/lang/Object; + public fun invalidate ()Z + public fun put (Ljava/lang/Object;Ljava/lang/Object;)V + public fun putIfAbsent (Ljava/lang/Object;Ljava/lang/Object;)Lorg/springframework/cache/Cache$ValueWrapper; + public fun retrieve (Ljava/lang/Object;)Ljava/util/concurrent/CompletableFuture; + public fun retrieve (Ljava/lang/Object;Ljava/util/function/Supplier;)Ljava/util/concurrent/CompletableFuture; +} + public abstract interface annotation class io/sentry/spring/jakarta/checkin/SentryCheckIn : java/lang/annotation/Annotation { public abstract fun heartbeat ()Z public abstract fun monitorSlug ()Ljava/lang/String; diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessor.java new file mode 100644 index 00000000000..ec9964f7abc --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessor.java @@ -0,0 +1,29 @@ +package io.sentry.spring.jakarta.cache; + +import io.sentry.ScopesAdapter; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.cache.CacheManager; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; + +/** Wraps {@link CacheManager} beans in {@link SentryCacheManagerWrapper} for instrumentation. */ +@ApiStatus.Internal +public final class SentryCacheBeanPostProcessor implements BeanPostProcessor, PriorityOrdered { + + @Override + public @NotNull Object postProcessAfterInitialization( + final @NotNull Object bean, final @NotNull String beanName) throws BeansException { + if (bean instanceof CacheManager && !(bean instanceof SentryCacheManagerWrapper)) { + return new SentryCacheManagerWrapper((CacheManager) bean, ScopesAdapter.getInstance()); + } + return bean; + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheManagerWrapper.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheManagerWrapper.java new file mode 100644 index 00000000000..ed243e973a2 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheManagerWrapper.java @@ -0,0 +1,37 @@ +package io.sentry.spring.jakarta.cache; + +import io.sentry.IScopes; +import java.util.Collection; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +/** Wraps a Spring {@link CacheManager} to return Sentry-instrumented caches. */ +@ApiStatus.Internal +public final class SentryCacheManagerWrapper implements CacheManager { + + private final @NotNull CacheManager delegate; + private final @NotNull IScopes scopes; + + public SentryCacheManagerWrapper( + final @NotNull CacheManager delegate, final @NotNull IScopes scopes) { + this.delegate = delegate; + this.scopes = scopes; + } + + @Override + public @Nullable Cache getCache(final @NotNull String name) { + final Cache cache = delegate.getCache(name); + if (cache == null || cache instanceof SentryCacheWrapper) { + return cache; + } + return new SentryCacheWrapper(cache, scopes); + } + + @Override + public @NotNull Collection getCacheNames() { + return delegate.getCacheNames(); + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheWrapper.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheWrapper.java new file mode 100644 index 00000000000..6253ede412e --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheWrapper.java @@ -0,0 +1,303 @@ +package io.sentry.spring.jakarta.cache; + +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import java.util.Arrays; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.cache.Cache; + +/** Wraps a Spring {@link Cache} to create Sentry spans for cache operations. */ +@ApiStatus.Internal +public final class SentryCacheWrapper implements Cache { + + private static final String TRACE_ORIGIN = "auto.cache.spring"; + + private final @NotNull Cache delegate; + private final @NotNull IScopes scopes; + + public SentryCacheWrapper(final @NotNull Cache delegate, final @NotNull IScopes scopes) { + this.delegate = delegate; + this.scopes = scopes; + } + + @Override + public @NotNull String getName() { + return delegate.getName(); + } + + @Override + public @NotNull Object getNativeCache() { + return delegate.getNativeCache(); + } + + @Override + public @Nullable ValueWrapper get(final @NotNull Object key) { + final ISpan span = startSpan("cache.get", key); + if (span == null) { + return delegate.get(key); + } + try { + final ValueWrapper result = delegate.get(key); + span.setData(SpanDataConvention.CACHE_HIT_KEY, result != null); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public @Nullable T get(final @NotNull Object key, final @Nullable Class type) { + final ISpan span = startSpan("cache.get", key); + if (span == null) { + return delegate.get(key, type); + } + try { + final T result = delegate.get(key, type); + span.setData(SpanDataConvention.CACHE_HIT_KEY, result != null); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public @Nullable T get(final @NotNull Object key, final @NotNull Callable valueLoader) { + final ISpan span = startSpan("cache.get", key); + if (span == null) { + return delegate.get(key, valueLoader); + } + try { + final AtomicBoolean loaderInvoked = new AtomicBoolean(false); + final T result = + delegate.get( + key, + () -> { + loaderInvoked.set(true); + return valueLoader.call(); + }); + span.setData(SpanDataConvention.CACHE_HIT_KEY, !loaderInvoked.get()); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public @Nullable CompletableFuture retrieve(final @NotNull Object key) { + final ISpan span = startSpan("cache.get", key); + if (span == null) { + return delegate.retrieve(key); + } + final CompletableFuture result; + try { + result = delegate.retrieve(key); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + span.finish(); + throw e; + } + if (result == null) { + span.setData(SpanDataConvention.CACHE_HIT_KEY, false); + span.setStatus(SpanStatus.OK); + span.finish(); + return null; + } + return result.whenComplete( + (value, throwable) -> { + if (throwable != null) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(throwable); + } else { + span.setData(SpanDataConvention.CACHE_HIT_KEY, value != null); + span.setStatus(SpanStatus.OK); + } + span.finish(); + }); + } + + @Override + public CompletableFuture retrieve( + final @NotNull Object key, final @NotNull Supplier> valueLoader) { + final ISpan span = startSpan("cache.get", key); + if (span == null) { + return delegate.retrieve(key, valueLoader); + } + final AtomicBoolean loaderInvoked = new AtomicBoolean(false); + final CompletableFuture result; + try { + result = + delegate.retrieve( + key, + () -> { + loaderInvoked.set(true); + return valueLoader.get(); + }); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + span.finish(); + throw e; + } + return result.whenComplete( + (value, throwable) -> { + if (throwable != null) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(throwable); + } else { + span.setData(SpanDataConvention.CACHE_HIT_KEY, !loaderInvoked.get()); + span.setStatus(SpanStatus.OK); + } + span.finish(); + }); + } + + @Override + public void put(final @NotNull Object key, final @Nullable Object value) { + final ISpan span = startSpan("cache.put", key); + if (span == null) { + delegate.put(key, value); + return; + } + try { + delegate.put(key, value); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + // putIfAbsent is not instrumented — we cannot know ahead of time whether the put + // will actually happen, and emitting a cache.put span for a no-op would be misleading. + // This matches sentry-python and sentry-javascript which also skip conditional puts. + // We must override to bypass the default implementation which calls this.get() + this.put(). + @Override + public @Nullable ValueWrapper putIfAbsent( + final @NotNull Object key, final @Nullable Object value) { + return delegate.putIfAbsent(key, value); + } + + @Override + public void evict(final @NotNull Object key) { + final ISpan span = startSpan("cache.remove", key); + if (span == null) { + delegate.evict(key); + return; + } + try { + delegate.evict(key); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean evictIfPresent(final @NotNull Object key) { + final ISpan span = startSpan("cache.remove", key); + if (span == null) { + return delegate.evictIfPresent(key); + } + try { + final boolean result = delegate.evictIfPresent(key); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public void clear() { + final ISpan span = startSpan("cache.flush", null); + if (span == null) { + delegate.clear(); + return; + } + try { + delegate.clear(); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean invalidate() { + final ISpan span = startSpan("cache.flush", null); + if (span == null) { + return delegate.invalidate(); + } + try { + final boolean result = delegate.invalidate(); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + private @Nullable ISpan startSpan(final @NotNull String operation, final @Nullable Object key) { + if (!scopes.getOptions().isEnableCacheTracing()) { + return null; + } + + final ISpan activeSpan = scopes.getSpan(); + if (activeSpan == null || activeSpan.isNoOp()) { + return null; + } + + final SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(TRACE_ORIGIN); + final String keyString = key != null ? String.valueOf(key) : null; + final ISpan span = activeSpan.startChild(operation, keyString, spanOptions); + if (keyString != null) { + span.setData(SpanDataConvention.CACHE_KEY_KEY, Arrays.asList(keyString)); + } + return span; + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessorTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessorTest.kt new file mode 100644 index 00000000000..301678d35d9 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheBeanPostProcessorTest.kt @@ -0,0 +1,44 @@ +package io.sentry.spring.jakarta.cache + +import io.sentry.IScopes +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.springframework.cache.CacheManager + +class SentryCacheBeanPostProcessorTest { + + private val scopes: IScopes = mock() + + @Test + fun `wraps CacheManager beans in SentryCacheManagerWrapper`() { + val cacheManager = mock() + val processor = SentryCacheBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(cacheManager, "cacheManager") + + assertTrue(result is SentryCacheManagerWrapper) + } + + @Test + fun `does not double-wrap SentryCacheManagerWrapper`() { + val delegate = mock() + val alreadyWrapped = SentryCacheManagerWrapper(delegate, scopes) + val processor = SentryCacheBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(alreadyWrapped, "cacheManager") + + assertSame(alreadyWrapped, result) + } + + @Test + fun `does not wrap non-CacheManager beans`() { + val someBean = "not a cache manager" + val processor = SentryCacheBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(someBean, "someBean") + + assertSame(someBean, result) + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheManagerWrapperTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheManagerWrapperTest.kt new file mode 100644 index 00000000000..05daa207d37 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheManagerWrapperTest.kt @@ -0,0 +1,61 @@ +package io.sentry.spring.jakarta.cache + +import io.sentry.IScopes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.cache.Cache +import org.springframework.cache.CacheManager + +class SentryCacheManagerWrapperTest { + + private val scopes: IScopes = mock() + private val delegate: CacheManager = mock() + + @Test + fun `getCache wraps returned cache in SentryCacheWrapper`() { + val cache = mock() + whenever(delegate.getCache("test")).thenReturn(cache) + + val wrapper = SentryCacheManagerWrapper(delegate, scopes) + val result = wrapper.getCache("test") + + assertTrue(result is SentryCacheWrapper) + } + + @Test + fun `getCache returns null when delegate returns null`() { + whenever(delegate.getCache("missing")).thenReturn(null) + + val wrapper = SentryCacheManagerWrapper(delegate, scopes) + val result = wrapper.getCache("missing") + + assertNull(result) + } + + @Test + fun `getCache does not double-wrap SentryCacheWrapper`() { + val innerCache = mock() + val alreadyWrapped = SentryCacheWrapper(innerCache, scopes) + whenever(delegate.getCache("test")).thenReturn(alreadyWrapped) + + val wrapper = SentryCacheManagerWrapper(delegate, scopes) + val result = wrapper.getCache("test") + + assertSame(alreadyWrapped, result) + } + + @Test + fun `getCacheNames delegates to underlying cache manager`() { + whenever(delegate.cacheNames).thenReturn(listOf("cache1", "cache2")) + + val wrapper = SentryCacheManagerWrapper(delegate, scopes) + val result = wrapper.cacheNames + + assertEquals(listOf("cache1", "cache2"), result) + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheWrapperTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheWrapperTest.kt new file mode 100644 index 00000000000..c618688ebb9 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheWrapperTest.kt @@ -0,0 +1,463 @@ +package io.sentry.spring.jakarta.cache + +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture +import java.util.function.Supplier +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.cache.Cache + +class SentryCacheWrapperTest { + + private lateinit var scopes: IScopes + private lateinit var delegate: Cache + private lateinit var options: SentryOptions + + @BeforeTest + fun setup() { + scopes = mock() + delegate = mock() + options = SentryOptions().apply { isEnableCacheTracing = true } + whenever(scopes.options).thenReturn(options) + whenever(delegate.name).thenReturn("testCache") + } + + private fun createTransaction(): SentryTracer { + val tx = SentryTracer(TransactionContext("tx", "op"), scopes) + whenever(scopes.span).thenReturn(tx) + return tx + } + + // -- get(Object key) -- + + @Test + fun `get with ValueWrapper creates span with cache hit true on hit`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val valueWrapper = mock() + whenever(delegate.get("myKey")).thenReturn(valueWrapper) + + val result = wrapper.get("myKey") + + assertEquals(valueWrapper, result) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.get", span.operation) + assertEquals("myKey", span.description) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_HIT_KEY)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY_KEY)) + assertEquals("auto.cache.spring", span.spanContext.origin) + } + + @Test + fun `get with ValueWrapper creates span with cache hit false on miss`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.get("myKey")).thenReturn(null) + + val result = wrapper.get("myKey") + + assertNull(result) + assertEquals(1, tx.spans.size) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + } + + // -- get(Object key, Class) -- + + @Test + fun `get with type creates span with cache hit true on hit`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.get("myKey", String::class.java)).thenReturn("value") + + val result = wrapper.get("myKey", String::class.java) + + assertEquals("value", result) + assertEquals(1, tx.spans.size) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + } + + @Test + fun `get with type creates span with cache hit false on miss`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.get("myKey", String::class.java)).thenReturn(null) + + val result = wrapper.get("myKey", String::class.java) + + assertNull(result) + assertEquals(1, tx.spans.size) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + } + + // -- get(Object key, Callable) -- + + @Test + fun `get with callable creates span with cache hit true on hit`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + // Simulate cache hit: delegate returns value without invoking the loader + whenever(delegate.get(eq("myKey"), any>())).thenReturn("cached") + + val result = wrapper.get("myKey", Callable { "loaded" }) + + assertEquals("cached", result) + assertEquals(1, tx.spans.size) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + } + + @Test + fun `get with callable creates span with cache hit false on miss`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + // Simulate cache miss: delegate invokes the loader callable + whenever(delegate.get(eq("myKey"), any>())).thenAnswer { invocation -> + val loader = invocation.getArgument>(1) + loader.call() + } + + val result = wrapper.get("myKey", Callable { "loaded" }) + + assertEquals("loaded", result) + assertEquals(1, tx.spans.size) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + } + + // -- retrieve(Object key) -- + + @Test + fun `retrieve creates span with cache hit true when future resolves with value`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.retrieve("myKey")).thenReturn(CompletableFuture.completedFuture("value")) + + val result = wrapper.retrieve("myKey") + + assertEquals("value", result!!.get()) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.get", span.operation) + assertEquals("myKey", span.description) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_HIT_KEY)) + assertTrue(span.isFinished) + } + + @Test + fun `retrieve creates span with cache hit false when future resolves with null`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.retrieve("myKey")).thenReturn(CompletableFuture.completedFuture(null)) + + val result = wrapper.retrieve("myKey") + + assertNull(result!!.get()) + assertEquals(1, tx.spans.size) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + assertTrue(tx.spans.first().isFinished) + } + + @Test + fun `retrieve creates span with cache hit false when delegate returns null`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.retrieve("myKey")).thenReturn(null) + + val result = wrapper.retrieve("myKey") + + assertNull(result) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(false, span.getData(SpanDataConvention.CACHE_HIT_KEY)) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } + + @Test + fun `retrieve sets error status when future completes exceptionally`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val exception = RuntimeException("async cache error") + whenever(delegate.retrieve("myKey")) + .thenReturn(CompletableFuture().also { it.completeExceptionally(exception) }) + + val result = wrapper.retrieve("myKey") + + assertFailsWith { result!!.get() } + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + assertTrue(span.isFinished) + } + + @Test + fun `retrieve sets error status when delegate throws synchronously`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val exception = RuntimeException("sync error") + whenever(delegate.retrieve("myKey")).thenThrow(exception) + + assertFailsWith { wrapper.retrieve("myKey") } + + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + assertTrue(span.isFinished) + } + + @Test + fun `retrieve does not create span when tracing is disabled`() { + options.isEnableCacheTracing = false + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.retrieve("myKey")).thenReturn(CompletableFuture.completedFuture("value")) + + wrapper.retrieve("myKey") + + verify(delegate).retrieve("myKey") + assertEquals(0, tx.spans.size) + } + + // -- retrieve(Object key, Supplier>) -- + + @Test + fun `retrieve with loader creates span with cache hit true when loader not invoked`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + // Simulate cache hit: delegate returns value without invoking the loader + whenever(delegate.retrieve(eq("myKey"), any>>())) + .thenReturn(CompletableFuture.completedFuture("cached")) + + val result = wrapper.retrieve("myKey") { CompletableFuture.completedFuture("loaded") } + + assertEquals("cached", result.get()) + assertEquals(1, tx.spans.size) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + assertTrue(tx.spans.first().isFinished) + } + + @Test + fun `retrieve with loader creates span with cache hit false when loader invoked`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + // Simulate cache miss: delegate invokes the loader supplier + whenever(delegate.retrieve(eq("myKey"), any>>())) + .thenAnswer { invocation -> + val loader = invocation.getArgument>>(1) + loader.get() + } + + val result = wrapper.retrieve("myKey") { CompletableFuture.completedFuture("loaded") } + + assertEquals("loaded", result.get()) + assertEquals(1, tx.spans.size) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + assertTrue(tx.spans.first().isFinished) + } + + @Test + fun `retrieve with loader sets error status when future completes exceptionally`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val exception = RuntimeException("async loader error") + whenever(delegate.retrieve(eq("myKey"), any>>())) + .thenReturn(CompletableFuture().also { it.completeExceptionally(exception) }) + + val result = wrapper.retrieve("myKey") { CompletableFuture.completedFuture("loaded") } + + assertFailsWith { result.get() } + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + assertTrue(span.isFinished) + } + + @Test + fun `retrieve with loader does not create span when tracing is disabled`() { + options.isEnableCacheTracing = false + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.retrieve(eq("myKey"), any>>())) + .thenReturn(CompletableFuture.completedFuture("cached")) + + wrapper.retrieve("myKey") { CompletableFuture.completedFuture("loaded") } + + verify(delegate).retrieve(eq("myKey"), any>>()) + assertEquals(0, tx.spans.size) + } + + // -- put -- + + @Test + fun `put creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + + wrapper.put("myKey", "myValue") + + verify(delegate).put("myKey", "myValue") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.put", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY_KEY)) + } + + // -- putIfAbsent -- + + @Test + fun `putIfAbsent delegates without creating span`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.putIfAbsent("myKey", "myValue")).thenReturn(null) + + wrapper.putIfAbsent("myKey", "myValue") + + verify(delegate).putIfAbsent("myKey", "myValue") + assertEquals(0, tx.spans.size) + } + + // -- evict -- + + @Test + fun `evict creates cache remove span`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + + wrapper.evict("myKey") + + verify(delegate).evict("myKey") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.remove", span.operation) + assertEquals(SpanStatus.OK, span.status) + } + + // -- evictIfPresent -- + + @Test + fun `evictIfPresent creates cache remove span`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.evictIfPresent("myKey")).thenReturn(true) + + val result = wrapper.evictIfPresent("myKey") + + assertTrue(result) + assertEquals(1, tx.spans.size) + assertEquals("cache.remove", tx.spans.first().operation) + } + + // -- clear -- + + @Test + fun `clear creates cache flush span`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + + wrapper.clear() + + verify(delegate).clear() + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.flush", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertNull(span.getData(SpanDataConvention.CACHE_KEY_KEY)) + } + + // -- invalidate -- + + @Test + fun `invalidate creates cache flush span`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.invalidate()).thenReturn(true) + + val result = wrapper.invalidate() + + assertTrue(result) + assertEquals(1, tx.spans.size) + assertEquals("cache.flush", tx.spans.first().operation) + } + + // -- no span when no active transaction -- + + @Test + fun `does not create span when there is no active transaction`() { + whenever(scopes.span).thenReturn(null) + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.get("myKey")).thenReturn(null) + + wrapper.get("myKey") + + verify(delegate).get("myKey") + } + + // -- no span when option is disabled -- + + @Test + fun `does not create span when enableCacheTracing is false`() { + options.isEnableCacheTracing = false + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.get("myKey")).thenReturn(null) + + wrapper.get("myKey") + + verify(delegate).get("myKey") + assertEquals(0, tx.spans.size) + } + + // -- error handling -- + + @Test + fun `sets error status and throwable on exception`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val exception = RuntimeException("cache error") + whenever(delegate.get("myKey")).thenThrow(exception) + + assertFailsWith { wrapper.get("myKey") } + + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } + + // -- delegation -- + + @Test + fun `getName delegates to underlying cache`() { + val wrapper = SentryCacheWrapper(delegate, scopes) + assertEquals("testCache", wrapper.name) + } + + @Test + fun `getNativeCache delegates to underlying cache`() { + val nativeCache = Object() + whenever(delegate.nativeCache).thenReturn(nativeCache) + val wrapper = SentryCacheWrapper(delegate, scopes) + + assertEquals(nativeCache, wrapper.nativeCache) + } +} From 658e082fc372e2bb6ac0acaa676ceacba39743b9 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 13 Mar 2026 10:03:33 +0100 Subject: [PATCH 2/2] feat(samples): Add cache tracing to all Spring Boot 3 Jakarta samples Add CacheController, TodoService with @Cacheable/@CachePut/@CacheEvict, Caffeine cache config, and CacheSystemTest e2e tests to all four Jakarta sample modules: - sentry-samples-spring-boot-jakarta - sentry-samples-spring-boot-jakarta-opentelemetry - sentry-samples-spring-boot-jakarta-opentelemetry-noagent - sentry-samples-spring-boot-webflux-jakarta Co-Authored-By: Claude --- gradle/libs.versions.toml | 1 + .../build.gradle.kts | 4 ++ .../spring/boot/jakarta/CacheController.java | 34 +++++++++++++ .../boot/jakarta/SentryDemoApplication.java | 2 + .../spring/boot/jakarta/TodoService.java | 29 +++++++++++ .../src/main/resources/application.properties | 5 ++ .../io/sentry/systemtest/CacheSystemTest.kt | 51 +++++++++++++++++++ .../build.gradle.kts | 4 ++ .../spring/boot/jakarta/CacheController.java | 34 +++++++++++++ .../boot/jakarta/SentryDemoApplication.java | 2 + .../spring/boot/jakarta/TodoService.java | 29 +++++++++++ .../src/main/resources/application.properties | 5 ++ .../io/sentry/systemtest/CacheSystemTest.kt | 51 +++++++++++++++++++ .../build.gradle.kts | 4 ++ .../spring/boot/jakarta/CacheController.java | 34 +++++++++++++ .../boot/jakarta/SentryDemoApplication.java | 2 + .../spring/boot/jakarta/TodoService.java | 29 +++++++++++ .../src/main/resources/application.properties | 5 ++ .../io/sentry/systemtest/CacheSystemTest.kt | 51 +++++++++++++++++++ .../build.gradle.kts | 4 ++ .../spring/boot/jakarta/CacheController.java | 34 +++++++++++++ .../boot/jakarta/SentryDemoApplication.java | 2 + .../spring/boot/jakarta/TodoService.java | 29 +++++++++++ .../src/main/resources/application.properties | 5 ++ .../io/sentry/systemtest/CacheSystemTest.kt | 51 +++++++++++++++++++ 25 files changed, 501 insertions(+) create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 54731df1f24..368a87ac365 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -181,6 +181,7 @@ springboot3-starter-aop = { module = "org.springframework.boot:spring-boot-start springboot3-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springboot3" } springboot3-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot3" } springboot3-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot3" } +springboot3-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot3" } springboot4-otel = { module = "io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter", version.ref = "otelInstrumentation" } springboot4-resttestclient = { module = "org.springframework.boot:spring-boot-resttestclient", version.ref = "springboot4" } springboot4-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot4" } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index b0fbae0ddc4..86914467a6d 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -52,6 +52,10 @@ dependencies { implementation(projects.sentryAsyncProfiler) implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) + // cache tracing + implementation(libs.springboot3.starter.cache) + implementation(libs.caffeine) + // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java new file mode 100644 index 00000000000..1327d5e6e29 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot.jakarta; + +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/") +public class CacheController { + private final TodoService todoService; + + public CacheController(TodoService todoService) { + this.todoService = todoService; + } + + @GetMapping("{id}") + Todo get(@PathVariable Long id) { + return todoService.get(id); + } + + @PostMapping + Todo save(@RequestBody Todo todo) { + return todoService.save(todo); + } + + @DeleteMapping("{id}") + void delete(@PathVariable Long id) { + todoService.delete(id); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java index 7f412eaa0d6..8cbd7875b5f 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java @@ -11,6 +11,7 @@ import org.springframework.boot.SpringApplication; 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.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.quartz.CronTriggerFactoryBean; @@ -21,6 +22,7 @@ import org.springframework.web.reactive.function.client.WebClient; @SpringBootApplication +@EnableCaching @EnableScheduling public class SentryDemoApplication { public static void main(String[] args) { diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java new file mode 100644 index 00000000000..70a145558c2 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot.jakarta; + +import java.util.Map; +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; + +@Service +public class TodoService { + private final Map store = new ConcurrentHashMap<>(); + + @Cacheable(value = "todos", key = "#id") + public Todo get(Long id) { + return store.get(id); + } + + @CachePut(value = "todos", key = "#todo.id") + public Todo save(Todo todo) { + store.put(todo.getId(), todo); + return todo; + } + + @CacheEvict(value = "todos", key = "#id") + public void delete(Long id) { + store.remove(id); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application.properties index d19c33a3d1b..a3a59d290b1 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application.properties @@ -35,6 +35,11 @@ spring.graphql.graphiql.enabled=true spring.graphql.websocket.path=/graphql spring.quartz.job-store-type=memory +# Cache tracing +sentry.enable-cache-tracing=true +spring.cache.cache-names=todos +spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s + # OTEL configuration otel.propagators=tracecontext,baggage,sentry otel.logs.exporter=none diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt new file mode 100644 index 00000000000..7853750a8fb --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt @@ -0,0 +1,51 @@ +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.remove") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index 0eeaf30d2bd..37d7a94eec0 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -56,6 +56,10 @@ dependencies { implementation(libs.otel) implementation(projects.sentryAsyncProfiler) + // cache tracing + implementation(libs.springboot3.starter.cache) + implementation(libs.caffeine) + // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java new file mode 100644 index 00000000000..1327d5e6e29 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot.jakarta; + +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/") +public class CacheController { + private final TodoService todoService; + + public CacheController(TodoService todoService) { + this.todoService = todoService; + } + + @GetMapping("{id}") + Todo get(@PathVariable Long id) { + return todoService.get(id); + } + + @PostMapping + Todo save(@RequestBody Todo todo) { + return todoService.save(todo); + } + + @DeleteMapping("{id}") + void delete(@PathVariable Long id) { + todoService.delete(id); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java index a6eb46f4c74..cd550bfbadf 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java @@ -11,6 +11,7 @@ import org.springframework.boot.SpringApplication; 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.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.quartz.CronTriggerFactoryBean; @@ -21,6 +22,7 @@ import org.springframework.web.reactive.function.client.WebClient; @SpringBootApplication +@EnableCaching @EnableScheduling public class SentryDemoApplication { public static void main(String[] args) { diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java new file mode 100644 index 00000000000..70a145558c2 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot.jakarta; + +import java.util.Map; +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; + +@Service +public class TodoService { + private final Map store = new ConcurrentHashMap<>(); + + @Cacheable(value = "todos", key = "#id") + public Todo get(Long id) { + return store.get(id); + } + + @CachePut(value = "todos", key = "#todo.id") + public Todo save(Todo todo) { + store.put(todo.getId(), todo); + return todo; + } + + @CacheEvict(value = "todos", key = "#id") + public void delete(Long id) { + store.remove(id); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application.properties index 6b57706019b..12a9ca17269 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application.properties @@ -34,3 +34,8 @@ spring.datasource.password= spring.graphql.graphiql.enabled=true spring.graphql.websocket.path=/graphql spring.quartz.job-store-type=memory + +# Cache tracing +sentry.enable-cache-tracing=true +spring.cache.cache-names=todos +spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt new file mode 100644 index 00000000000..7853750a8fb --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt @@ -0,0 +1,51 @@ +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.remove") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 570d35b727b..a945b87109a 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -55,6 +55,10 @@ dependencies { implementation(projects.sentryAsyncProfiler) implementation(projects.sentryOpenfeature) + // cache tracing + implementation(libs.springboot3.starter.cache) + implementation(libs.caffeine) + // OpenFeature SDK implementation(libs.openfeature) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java new file mode 100644 index 00000000000..1327d5e6e29 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot.jakarta; + +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/") +public class CacheController { + private final TodoService todoService; + + public CacheController(TodoService todoService) { + this.todoService = todoService; + } + + @GetMapping("{id}") + Todo get(@PathVariable Long id) { + return todoService.get(id); + } + + @PostMapping + Todo save(@RequestBody Todo todo) { + return todoService.save(todo); + } + + @DeleteMapping("{id}") + void delete(@PathVariable Long id) { + todoService.delete(id); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java index 8050cb8e74c..e818cbe42ff 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java @@ -9,6 +9,7 @@ import org.springframework.boot.SpringApplication; 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.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.quartz.CronTriggerFactoryBean; @@ -19,6 +20,7 @@ import org.springframework.web.reactive.function.client.WebClient; @SpringBootApplication +@EnableCaching @EnableScheduling public class SentryDemoApplication { public static void main(String[] args) { diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java new file mode 100644 index 00000000000..70a145558c2 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot.jakarta; + +import java.util.Map; +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; + +@Service +public class TodoService { + private final Map store = new ConcurrentHashMap<>(); + + @Cacheable(value = "todos", key = "#id") + public Todo get(Long id) { + return store.get(id); + } + + @CachePut(value = "todos", key = "#todo.id") + public Todo save(Todo todo) { + store.put(todo.getId(), todo); + return todo; + } + + @CacheEvict(value = "todos", key = "#id") + public void delete(Long id) { + store.remove(id); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties index 9830709c313..60b92d369d5 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties @@ -35,3 +35,8 @@ spring.graphql.graphiql.enabled=true spring.graphql.websocket.path=/graphql spring.quartz.job-store-type=memory +# Cache tracing +sentry.enable-cache-tracing=true +spring.cache.cache-names=todos +spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s + 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 new file mode 100644 index 00000000000..7853750a8fb --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt @@ -0,0 +1,51 @@ +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.remove") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index 1cdff5cab38..a45249830f4 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -31,6 +31,10 @@ dependencies { implementation(libs.springboot3.starter.graphql) implementation(libs.springboot3.starter.webflux) + // cache tracing + implementation(libs.springboot3.starter.cache) + implementation(libs.caffeine) + testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(projects.sentrySystemTestSupport) testImplementation(libs.apollo3.kotlin) diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java new file mode 100644 index 00000000000..1327d5e6e29 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot.jakarta; + +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/") +public class CacheController { + private final TodoService todoService; + + public CacheController(TodoService todoService) { + this.todoService = todoService; + } + + @GetMapping("{id}") + Todo get(@PathVariable Long id) { + return todoService.get(id); + } + + @PostMapping + Todo save(@RequestBody Todo todo) { + return todoService.save(todo); + } + + @DeleteMapping("{id}") + void delete(@PathVariable Long id) { + todoService.delete(id); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java index 926298bb97b..baa6d30e5c3 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.web.reactive.function.client.WebClient; @SpringBootApplication +@EnableCaching public class SentryDemoApplication { public static void main(String[] args) { SpringApplication.run(SentryDemoApplication.class, args); diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java new file mode 100644 index 00000000000..70a145558c2 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot.jakarta; + +import java.util.Map; +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; + +@Service +public class TodoService { + private final Map store = new ConcurrentHashMap<>(); + + @Cacheable(value = "todos", key = "#id") + public Todo get(Long id) { + return store.get(id); + } + + @CachePut(value = "todos", key = "#todo.id") + public Todo save(Todo todo) { + store.put(todo.getId(), todo); + return todo; + } + + @CacheEvict(value = "todos", key = "#id") + public void delete(Long id) { + store.remove(id); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/application.properties index 3bc4087b288..02eaf0c731c 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/application.properties @@ -16,3 +16,8 @@ sentry.in-app-includes="io.sentry.samples" sentry.profile-session-sample-rate=1.0 sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces sentry.profile-lifecycle=TRACE + +# Cache tracing +sentry.enable-cache-tracing=true +spring.cache.cache-names=todos +spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt new file mode 100644 index 00000000000..7853750a8fb --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt @@ -0,0 +1,51 @@ +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.remove") + } + } +}