diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b59334d90..fabd81c5746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ ### Features +- Add cache tracing instrumentation for Spring Boot 2, 3, and 4 ([#5165](https://github.com/getsentry/sentry-java/pull/5165)) + - Wraps Spring `CacheManager` and `Cache` beans to produce cache spans + - Set `sentry.enable-cache-tracing` to `true` to enable this feature +- Add JCache (JSR-107) cache tracing via new `sentry-jcache` module ([#5165](https://github.com/getsentry/sentry-java/pull/5165)) + - Wraps JCache `Cache` with `SentryJCacheWrapper` to produce cache spans + - Set the `enableCacheTracing` option to `true` to enable this feature - Add configurable `IScopesStorageFactory` to `SentryOptions` for providing a custom `IScopesStorage`, e.g. when the default `ThreadLocal`-backed storage is incompatible with non-pinning thread models ([#5199](https://github.com/getsentry/sentry-java/pull/5199)) - Android: Add `beforeErrorSampling` callback to Session Replay ([#5214](https://github.com/getsentry/sentry-java/pull/5214)) - Allows filtering which errors trigger replay capture before the `onErrorSampleRate` is checked diff --git a/README.md b/README.md index 31285e2be29..25fedc8217f 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Sentry SDK for Java and Android | sentry-graphql | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-graphql?style=for-the-badge&logo=sentry&color=green) | | sentry-graphql-core | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-graphql-core?style=for-the-badge&logo=sentry&color=green) | | sentry-graphql-22 | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-graphql-22?style=for-the-badge&logo=sentry&color=green) | +| sentry-jcache | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-jcache?style=for-the-badge&logo=sentry&color=green) | | sentry-quartz | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-quartz?style=for-the-badge&logo=sentry&color=green) | | sentry-openfeign | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-openfeign?style=for-the-badge&logo=sentry&color=green) | | sentry-openfeature | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-openfeature?style=for-the-badge&logo=sentry&color=green) | diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 72892df5a9a..b5d1dafeb74 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -77,6 +77,7 @@ object Config { val SENTRY_GRAPHQL_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql" val SENTRY_GRAPHQL_CORE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql-core" val SENTRY_GRAPHQL22_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql22" + val SENTRY_JCACHE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jcache" val SENTRY_QUARTZ_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.quartz" val SENTRY_JDBC_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jdbc" val SENTRY_OPENFEATURE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.openfeature" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60be4fd2c03..eb7ab86e4bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -99,6 +99,8 @@ androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } async-profiler-jfr-converter = { module = "tools.profiler:jfr-converter", version.ref = "asyncProfiler" } +caffeine = { module = "com.github.ben-manes.caffeine:caffeine" } +caffeine-jcache = { module = "com.github.ben-manes.caffeine:jcache", version = "3.2.0" } coil-compose = { module = "io.coil-kt:coil-compose", version = "2.6.0" } commons-compress = {module = "org.apache.commons:commons-compress", version = "1.25.0"} context-propagation = { module = "io.micrometer:context-propagation", version = "1.1.0" } @@ -144,6 +146,7 @@ otel-semconv = { module = "io.opentelemetry.semconv:opentelemetry-semconv", vers otel-semconv-incubating = { module = "io.opentelemetry.semconv:opentelemetry-semconv-incubating", version.ref = "otelSemanticConventionsAlpha" } p6spy = { module = "p6spy:p6spy", version = "3.9.1" } epitaph = { module = "com.abovevacant:epitaph", version = "0.1.1" } +jcache = { module = "javax.cache:cache-api", version = "1.1.1" } quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } @@ -166,6 +169,7 @@ springboot-starter-aop = { module = "org.springframework.boot:spring-boot-starte springboot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springboot2" } springboot-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot2" } springboot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot2" } +springboot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot2" } springboot3-otel = { module = "io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter", version.ref = "otelInstrumentation" } springboot3-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot3" } springboot3-starter-graphql = { module = "org.springframework.boot:spring-boot-starter-graphql", version.ref = "springboot3" } @@ -178,6 +182,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" } @@ -193,6 +198,7 @@ springboot4-starter-restclient = { module = "org.springframework.boot:spring-boo springboot4-starter-webclient = { module = "org.springframework.boot:spring-boot-starter-webclient", version.ref = "springboot4" } springboot4-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot4" } springboot4-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot4" } +springboot4-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot4" } timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" } # Animalsniffer signature diff --git a/sentry-jcache/README.md b/sentry-jcache/README.md new file mode 100644 index 00000000000..e4f4d8e49a4 --- /dev/null +++ b/sentry-jcache/README.md @@ -0,0 +1,13 @@ +# sentry-jcache + +This module provides an integration for JCache (JSR-107). + +JCache is a standard API — you need a provider implementation at runtime. Common implementations include: + +- [Caffeine](https://github.com/ben-manes/caffeine) (via `com.github.ben-manes.caffeine:jcache`) +- [Ehcache 3](https://www.ehcache.org/) (via `org.ehcache:ehcache`) +- [Hazelcast](https://hazelcast.com/) +- [Apache Ignite](https://ignite.apache.org/) +- [Infinispan](https://infinispan.org/) + +Please consult the documentation on how to install and use this integration in the Sentry Docs for [Java](https://docs.sentry.io/platforms/java/integrations/jcache/). diff --git a/sentry-jcache/api/sentry-jcache.api b/sentry-jcache/api/sentry-jcache.api new file mode 100644 index 00000000000..b834ba41064 --- /dev/null +++ b/sentry-jcache/api/sentry-jcache.api @@ -0,0 +1,38 @@ +public final class io/sentry/jcache/BuildConfig { + public static final field SENTRY_JCACHE_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/jcache/SentryJCacheWrapper : javax/cache/Cache { + public fun (Ljavax/cache/Cache;)V + public fun (Ljavax/cache/Cache;Lio/sentry/IScopes;)V + public fun clear ()V + public fun close ()V + public fun containsKey (Ljava/lang/Object;)Z + public fun deregisterCacheEntryListener (Ljavax/cache/configuration/CacheEntryListenerConfiguration;)V + public fun get (Ljava/lang/Object;)Ljava/lang/Object; + public fun getAll (Ljava/util/Set;)Ljava/util/Map; + public fun getAndPut (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public fun getAndRemove (Ljava/lang/Object;)Ljava/lang/Object; + public fun getAndReplace (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public fun getCacheManager ()Ljavax/cache/CacheManager; + public fun getConfiguration (Ljava/lang/Class;)Ljavax/cache/configuration/Configuration; + public fun getName ()Ljava/lang/String; + public fun invoke (Ljava/lang/Object;Ljavax/cache/processor/EntryProcessor;[Ljava/lang/Object;)Ljava/lang/Object; + public fun invokeAll (Ljava/util/Set;Ljavax/cache/processor/EntryProcessor;[Ljava/lang/Object;)Ljava/util/Map; + public fun isClosed ()Z + public fun iterator ()Ljava/util/Iterator; + public fun loadAll (Ljava/util/Set;ZLjavax/cache/integration/CompletionListener;)V + public fun put (Ljava/lang/Object;Ljava/lang/Object;)V + public fun putAll (Ljava/util/Map;)V + public fun putIfAbsent (Ljava/lang/Object;Ljava/lang/Object;)Z + public fun registerCacheEntryListener (Ljavax/cache/configuration/CacheEntryListenerConfiguration;)V + public fun remove (Ljava/lang/Object;)Z + public fun remove (Ljava/lang/Object;Ljava/lang/Object;)Z + public fun removeAll ()V + public fun removeAll (Ljava/util/Set;)V + public fun replace (Ljava/lang/Object;Ljava/lang/Object;)Z + public fun replace (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Z + public fun unwrap (Ljava/lang/Class;)Ljava/lang/Object; +} + diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts new file mode 100644 index 00000000000..a9393a7d905 --- /dev/null +++ b/sentry-jcache/build.gradle.kts @@ -0,0 +1,90 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + jacoco + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 +} + +dependencies { + api(projects.sentry) + compileOnly(libs.jcache) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentry) + testImplementation(projects.sentryTestSupport) + testImplementation(libs.jcache) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) +} + +configure { test { java.srcDir("src/test/java") } } + +jacoco { toolVersion = libs.versions.jacoco.get() } + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.jcache") + buildConfigField( + "String", + "SENTRY_JCACHE_SDK_NAME", + "\"${Config.Sentry.SENTRY_JCACHE_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_JCACHE_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-jcache", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-jcache/src/main/java/io/sentry/jcache/SentryJCacheWrapper.java b/sentry-jcache/src/main/java/io/sentry/jcache/SentryJCacheWrapper.java new file mode 100644 index 00000000000..e1c3b786e85 --- /dev/null +++ b/sentry-jcache/src/main/java/io/sentry/jcache/SentryJCacheWrapper.java @@ -0,0 +1,506 @@ +package io.sentry.jcache; + +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.ScopesAdapter; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.cache.Cache; +import javax.cache.CacheManager; +import javax.cache.configuration.CacheEntryListenerConfiguration; +import javax.cache.configuration.Configuration; +import javax.cache.integration.CompletionListener; +import javax.cache.processor.EntryProcessor; +import javax.cache.processor.EntryProcessorException; +import javax.cache.processor.EntryProcessorResult; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Wraps a JCache {@link Cache} to create Sentry spans for cache operations. + * + * @param the type of key + * @param the type of value + */ +@ApiStatus.Experimental +public final class SentryJCacheWrapper implements Cache { + + private static final String TRACE_ORIGIN = "auto.cache.jcache"; + + private final @NotNull Cache delegate; + private final @NotNull IScopes scopes; + + public SentryJCacheWrapper(final @NotNull Cache delegate) { + this(delegate, ScopesAdapter.getInstance()); + } + + public SentryJCacheWrapper(final @NotNull Cache delegate, final @NotNull IScopes scopes) { + this.delegate = delegate; + this.scopes = scopes; + } + + // -- read operations -- + + @Override + public V get(final K key) { + final ISpan span = startSpan(key, "get"); + if (span == null) { + return delegate.get(key); + } + try { + final V result = delegate.get(key); + span.setData(SpanDataConvention.CACHE_HIT, 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 Map getAll(final Set keys) { + final ISpan span = startSpanForKeys(keys, "getAll"); + if (span == null) { + return delegate.getAll(keys); + } + try { + final Map result = delegate.getAll(keys); + span.setData(SpanDataConvention.CACHE_HIT, !result.isEmpty()); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean containsKey(final K key) { + return delegate.containsKey(key); + } + + // -- write operations -- + + @Override + public void put(final K key, final V value) { + final ISpan span = startSpan(key, "put"); + if (span == null) { + delegate.put(key, value); + return; + } + try { + delegate.put(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public V getAndPut(final K key, final V value) { + final ISpan span = startSpan(key, "getAndPut"); + if (span == null) { + return delegate.getAndPut(key, value); + } + try { + final V result = delegate.getAndPut(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, true); + 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 putAll(final Map map) { + final ISpan span = startSpanForKeys(map.keySet(), "putAll"); + if (span == null) { + delegate.putAll(map); + return; + } + try { + delegate.putAll(map); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean putIfAbsent(final K key, final V value) { + final ISpan span = startSpan(key, "putIfAbsent"); + if (span == null) { + return delegate.putIfAbsent(key, value); + } + try { + final boolean result = delegate.putIfAbsent(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, result); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean replace(final K key, final V oldValue, final V newValue) { + final ISpan span = startSpan(key, "replace"); + if (span == null) { + return delegate.replace(key, oldValue, newValue); + } + try { + final boolean result = delegate.replace(key, oldValue, newValue); + span.setData(SpanDataConvention.CACHE_WRITE, result); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean replace(final K key, final V value) { + final ISpan span = startSpan(key, "replace"); + if (span == null) { + return delegate.replace(key, value); + } + try { + final boolean result = delegate.replace(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, result); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public V getAndReplace(final K key, final V value) { + final ISpan span = startSpan(key, "getAndReplace"); + if (span == null) { + return delegate.getAndReplace(key, value); + } + try { + final V result = delegate.getAndReplace(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, result != null); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + // -- remove operations -- + + @Override + public boolean remove(final K key) { + final ISpan span = startSpan(key, "remove"); + if (span == null) { + return delegate.remove(key); + } + try { + final boolean result = delegate.remove(key); + span.setData(SpanDataConvention.CACHE_WRITE, result); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean remove(final K key, final V oldValue) { + final ISpan span = startSpan(key, "remove"); + if (span == null) { + return delegate.remove(key, oldValue); + } + try { + final boolean result = delegate.remove(key, oldValue); + span.setData(SpanDataConvention.CACHE_WRITE, result); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public V getAndRemove(final K key) { + final ISpan span = startSpan(key, "getAndRemove"); + if (span == null) { + return delegate.getAndRemove(key); + } + try { + final V result = delegate.getAndRemove(key); + span.setData(SpanDataConvention.CACHE_WRITE, 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 void removeAll(final Set keys) { + final ISpan span = startSpanForKeys(keys, "removeAll"); + if (span == null) { + delegate.removeAll(keys); + return; + } + try { + delegate.removeAll(keys); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public void removeAll() { + final ISpan span = startSpan(null, "removeAll"); + if (span == null) { + delegate.removeAll(); + return; + } + try { + delegate.removeAll(); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + // -- flush operations -- + + @Override + public void clear() { + final ISpan span = startSpan(null, "clear"); + if (span == null) { + delegate.clear(); + return; + } + try { + delegate.clear(); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public void close() { + delegate.close(); + } + + // -- entry processor operations -- + + @Override + public T invoke( + final K key, final EntryProcessor entryProcessor, final Object... arguments) + throws EntryProcessorException { + final ISpan span = startSpan(key, "invoke"); + if (span == null) { + return delegate.invoke(key, entryProcessor, arguments); + } + try { + final T result = delegate.invoke(key, entryProcessor, arguments); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public Map> invokeAll( + final Set keys, + final EntryProcessor entryProcessor, + final Object... arguments) { + final ISpan span = startSpanForKeys(keys, "invokeAll"); + if (span == null) { + return delegate.invokeAll(keys, entryProcessor, arguments); + } + try { + final Map> result = + delegate.invokeAll(keys, entryProcessor, arguments); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + // -- passthrough operations -- + + @Override + public void loadAll( + final Set keys, + final boolean replaceExistingValues, + final CompletionListener completionListener) { + delegate.loadAll(keys, replaceExistingValues, completionListener); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public CacheManager getCacheManager() { + return delegate.getCacheManager(); + } + + @Override + public > C getConfiguration(final Class clazz) { + return delegate.getConfiguration(clazz); + } + + @Override + public boolean isClosed() { + return delegate.isClosed(); + } + + @Override + public T unwrap(final Class clazz) { + return delegate.unwrap(clazz); + } + + @Override + public void registerCacheEntryListener( + final CacheEntryListenerConfiguration cacheEntryListenerConfiguration) { + delegate.registerCacheEntryListener(cacheEntryListenerConfiguration); + } + + @Override + public void deregisterCacheEntryListener( + final CacheEntryListenerConfiguration cacheEntryListenerConfiguration) { + delegate.deregisterCacheEntryListener(cacheEntryListenerConfiguration); + } + + @Override + public Iterator> iterator() { + return delegate.iterator(); + } + + // -- span helpers -- + + private @Nullable ISpan startSpan( + final @Nullable Object key, final @NotNull String operationName) { + final String keyString = key != null ? String.valueOf(key) : null; + return startSpan( + operationName, keyString, keyString != null ? Collections.singletonList(keyString) : null); + } + + private @Nullable ISpan startSpanForKeys( + final @NotNull Set keys, final @NotNull String operationName) { + final List keyStrings = keys.stream().map(String::valueOf).collect(Collectors.toList()); + return startSpan(operationName, String.join(", ", keyStrings), keyStrings); + } + + private @Nullable ISpan startSpan( + final @NotNull String operationName, + final @Nullable String description, + final @Nullable List cacheKeys) { + 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 ISpan span = activeSpan.startChild("cache." + operationName, description, spanOptions); + if (span.isNoOp()) { + return null; + } + if (cacheKeys != null) { + span.setData(SpanDataConvention.CACHE_KEY, cacheKeys); + } + span.setData(SpanDataConvention.CACHE_OPERATION, operationName); + return span; + } +} diff --git a/sentry-jcache/src/test/kotlin/io/sentry/jcache/SentryJCacheWrapperTest.kt b/sentry-jcache/src/test/kotlin/io/sentry/jcache/SentryJCacheWrapperTest.kt new file mode 100644 index 00000000000..9e523d3df21 --- /dev/null +++ b/sentry-jcache/src/test/kotlin/io/sentry/jcache/SentryJCacheWrapperTest.kt @@ -0,0 +1,534 @@ +package io.sentry.jcache + +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 javax.cache.Cache +import javax.cache.CacheManager +import javax.cache.configuration.CacheEntryListenerConfiguration +import javax.cache.configuration.Configuration +import javax.cache.integration.CompletionListener +import javax.cache.processor.EntryProcessor +import javax.cache.processor.EntryProcessorResult +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryJCacheWrapperTest { + + 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(K key) -- + + @Test + fun `get creates span with cache hit true on hit`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.get("myKey")).thenReturn("value") + + val result = wrapper.get("myKey") + + assertEquals("value", 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)) + assertNull(span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("auto.cache.jcache", span.spanContext.origin) + assertEquals("get", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + @Test + fun `get creates span with cache hit false on miss`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(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)) + } + + // -- getAll -- + + @Test + fun `getAll creates span with cache hit true when results exist`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val keys = setOf("k1", "k2") + whenever(delegate.getAll(keys)).thenReturn(mapOf("k1" to "v1")) + + val result = wrapper.getAll(keys) + + assertEquals(mapOf("k1" to "v1"), result) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.getAll", span.operation) + assertEquals("k1, k2", span.description) + assertEquals(true, span.getData(SpanDataConvention.CACHE_HIT)) + assertEquals(listOf("k1", "k2"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("getAll", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + @Test + fun `getAll creates span with cache hit false when empty`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val keys = setOf("k1") + whenever(delegate.getAll(keys)).thenReturn(emptyMap()) + + wrapper.getAll(keys) + + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT)) + } + + // -- put -- + + @Test + fun `put creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(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(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("put", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- getAndPut -- + + @Test + fun `getAndPut creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.getAndPut("myKey", "newValue")).thenReturn("oldValue") + + val result = wrapper.getAndPut("myKey", "newValue") + + assertEquals("oldValue", result) + assertEquals(1, tx.spans.size) + assertEquals("cache.getAndPut", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("getAndPut", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- putAll -- + + @Test + fun `putAll creates cache put span with all keys`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val entries = mapOf("k1" to "v1", "k2" to "v2") + + wrapper.putAll(entries) + + verify(delegate).putAll(entries) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.putAll", span.operation) + assertEquals("k1, k2", span.description) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("k1", "k2"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("putAll", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- putIfAbsent -- + + @Test + fun `putIfAbsent creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.putIfAbsent("myKey", "myValue")).thenReturn(true) + + val result = wrapper.putIfAbsent("myKey", "myValue") + + assertTrue(result) + verify(delegate).putIfAbsent("myKey", "myValue") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.putIfAbsent", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("putIfAbsent", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- replace -- + + @Test + fun `replace with old value creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.replace("myKey", "old", "new")).thenReturn(true) + + val result = wrapper.replace("myKey", "old", "new") + + assertTrue(result) + verify(delegate).replace("myKey", "old", "new") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.replace", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("replace", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + @Test + fun `replace creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.replace("myKey", "value")).thenReturn(true) + + val result = wrapper.replace("myKey", "value") + + assertTrue(result) + verify(delegate).replace("myKey", "value") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.replace", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("replace", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- getAndReplace -- + + @Test + fun `getAndReplace creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.getAndReplace("myKey", "newValue")).thenReturn("oldValue") + + val result = wrapper.getAndReplace("myKey", "newValue") + + assertEquals("oldValue", result) + verify(delegate).getAndReplace("myKey", "newValue") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.getAndReplace", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("getAndReplace", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- remove(K) -- + + @Test + fun `remove creates cache remove span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.remove("myKey")).thenReturn(true) + + val result = wrapper.remove("myKey") + + assertTrue(result) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.remove", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("remove", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- remove(K, V) -- + + @Test + fun `remove with value creates cache remove span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.remove("myKey", "myValue")).thenReturn(true) + + val result = wrapper.remove("myKey", "myValue") + + assertTrue(result) + assertEquals(1, tx.spans.size) + assertEquals("cache.remove", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("remove", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- getAndRemove -- + + @Test + fun `getAndRemove creates cache remove span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.getAndRemove("myKey")).thenReturn("value") + + val result = wrapper.getAndRemove("myKey") + + assertEquals("value", result) + assertEquals(1, tx.spans.size) + assertEquals("cache.getAndRemove", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("getAndRemove", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- removeAll(Set) -- + + @Test + fun `removeAll with keys creates cache remove span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val keys = setOf("k1", "k2") + + wrapper.removeAll(keys) + + verify(delegate).removeAll(keys) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.removeAll", span.operation) + assertEquals("k1, k2", span.description) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("k1", "k2"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("removeAll", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- removeAll() -- + + @Test + fun `removeAll without keys creates cache flush span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + + wrapper.removeAll() + + verify(delegate).removeAll() + assertEquals(1, tx.spans.size) + assertEquals("cache.removeAll", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("removeAll", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- clear -- + + @Test + fun `clear creates cache flush span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + + wrapper.clear() + + verify(delegate).clear() + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.clear", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertNull(span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("clear", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- invoke -- + + @Test + fun `invoke creates cache get span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val processor = mock>() + whenever(delegate.invoke("myKey", processor)).thenReturn("result") + + val result = wrapper.invoke("myKey", processor) + + assertEquals("result", result) + assertEquals(1, tx.spans.size) + assertEquals("cache.invoke", tx.spans.first().operation) + assertEquals("invoke", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- invokeAll -- + + @Test + fun `invokeAll creates cache get span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + val processor = mock>() + val keys = setOf("k1", "k2") + val resultMap = mock>>() + whenever(delegate.invokeAll(keys, processor)).thenReturn(resultMap) + + val result = wrapper.invokeAll(keys, processor) + + assertEquals(resultMap, result) + assertEquals(1, tx.spans.size) + assertEquals("cache.invokeAll", tx.spans.first().operation) + assertEquals("invokeAll", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- passthrough operations -- + + @Test + fun `containsKey delegates without creating span`() { + val tx = createTransaction() + val wrapper = SentryJCacheWrapper(delegate, scopes) + whenever(delegate.containsKey("myKey")).thenReturn(true) + + assertTrue(wrapper.containsKey("myKey")) + assertEquals(0, tx.spans.size) + } + + @Test + fun `getName delegates to underlying cache`() { + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertEquals("testCache", wrapper.name) + } + + @Test + fun `getCacheManager delegates to underlying cache`() { + val manager = mock() + whenever(delegate.cacheManager).thenReturn(manager) + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertEquals(manager, wrapper.cacheManager) + } + + @Test + fun `isClosed delegates to underlying cache`() { + whenever(delegate.isClosed).thenReturn(false) + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertFalse(wrapper.isClosed) + } + + @Test + fun `close delegates to underlying cache`() { + val wrapper = SentryJCacheWrapper(delegate, scopes) + wrapper.close() + verify(delegate).close() + } + + @Test + fun `registerCacheEntryListener delegates to underlying cache`() { + val wrapper = SentryJCacheWrapper(delegate, scopes) + val config = mock>() + wrapper.registerCacheEntryListener(config) + verify(delegate).registerCacheEntryListener(config) + } + + @Test + fun `deregisterCacheEntryListener delegates to underlying cache`() { + val wrapper = SentryJCacheWrapper(delegate, scopes) + val config = mock>() + wrapper.deregisterCacheEntryListener(config) + verify(delegate).deregisterCacheEntryListener(config) + } + + @Test + fun `iterator delegates to underlying cache`() { + val iter = mock>>() + whenever(delegate.iterator()).thenReturn(iter) + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertEquals(iter, wrapper.iterator()) + } + + @Test + fun `loadAll delegates to underlying cache`() { + val wrapper = SentryJCacheWrapper(delegate, scopes) + val keys = setOf("k1") + val listener = mock() + wrapper.loadAll(keys, true, listener) + verify(delegate).loadAll(keys, true, listener) + } + + @Test + fun `getConfiguration delegates to underlying cache`() { + val config = mock>() + whenever( + delegate.getConfiguration(Configuration::class.java as Class>) + ) + .thenReturn(config) + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertEquals( + config, + wrapper.getConfiguration(Configuration::class.java as Class>), + ) + } + + @Test + fun `unwrap delegates to underlying cache`() { + whenever(delegate.unwrap(String::class.java)).thenReturn("unwrapped") + val wrapper = SentryJCacheWrapper(delegate, scopes) + assertEquals("unwrapped", wrapper.unwrap(String::class.java)) + } + + // -- 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 = SentryJCacheWrapper(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 = SentryJCacheWrapper(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 = SentryJCacheWrapper(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) + } +} diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index 5737e8effe0..0dc6183b4fc 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -35,6 +35,9 @@ tasks.withType().configureEach { dependencies { implementation(projects.sentry) implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryJcache) + implementation(libs.jcache) + implementation(libs.caffeine.jcache) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(projects.sentry) diff --git a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java index 29fae9381b2..0ed0646c7bc 100644 --- a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java +++ b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java @@ -2,9 +2,14 @@ import io.sentry.*; import io.sentry.clientreport.DiscardReason; +import io.sentry.jcache.SentryJCacheWrapper; import io.sentry.protocol.Message; import io.sentry.protocol.User; import java.util.Collections; +import javax.cache.Cache; +import javax.cache.CacheManager; +import javax.cache.Caching; +import javax.cache.configuration.MutableConfiguration; public class Main { @@ -88,6 +93,9 @@ public static void main(String[] args) throws InterruptedException { // Set what percentage of traces should be collected options.setTracesSampleRate(1.0); // set 0.5 to send 50% of traces + // Enable cache tracing to create spans for cache operations + options.setEnableCacheTracing(true); + // Determine traces sample rate based on the sampling context // options.setTracesSampler( // context -> { @@ -164,6 +172,12 @@ public static void main(String[] args) throws InterruptedException { Sentry.captureEvent(event, hint); } + // Cache tracing with JCache (JSR-107) + // + // Wrapping a JCache Cache with SentryJCacheWrapper creates cache.get, cache.put, + // cache.remove, and cache.flush spans as children of the active transaction. + demonstrateCacheTracing(); + // Performance feature // // Transactions collect execution time of the piece of code that's executed between the start @@ -191,6 +205,42 @@ public static void main(String[] args) throws InterruptedException { // Sentry.close(); } + private static void demonstrateCacheTracing() { + // Create a JCache CacheManager and Cache using standard JSR-107 API + CacheManager cacheManager = Caching.getCachingProvider().getCacheManager(); + MutableConfiguration config = + new MutableConfiguration().setTypes(String.class, String.class); + Cache rawCache = cacheManager.createCache("myCache", config); + + // Wrap with SentryJCacheWrapper to enable cache tracing + Cache cache = new SentryJCacheWrapper<>(rawCache); + + // All cache operations inside a transaction produce child spans + ITransaction transaction = Sentry.startTransaction("cache-demo", "demo"); + try (ISentryLifecycleToken ignored = transaction.makeCurrent()) { + // cache.put span + cache.put("greeting", "hello"); + + // cache.get span (hit — returns "hello", cache.hit = true) + cache.get("greeting"); + + // cache.get span (miss — returns null, cache.hit = false) + cache.get("nonexistent"); + + // cache.remove span + cache.remove("greeting"); + + // cache.flush span + cache.clear(); + } finally { + transaction.finish(); + } + + // Clean up + cacheManager.destroyCache("myCache"); + cacheManager.close(); + } + private static void captureMetrics() { Sentry.metrics().count("countMetric"); Sentry.metrics().gauge("gaugeMetric", 5.0); diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts index ef38162d6bf..c3e8ba06fae 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts @@ -59,6 +59,10 @@ dependencies { implementation(projects.sentryAsyncProfiler) implementation(libs.otel) + // cache tracing + implementation(libs.springboot4.starter.cache) + implementation(libs.caffeine) + // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/CacheController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/CacheController.java new file mode 100644 index 00000000000..3c2e66442de --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot4; + +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-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java index aa5ebce68cd..b00609ad9ac 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java @@ -11,6 +11,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.restclient.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-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/TodoService.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/TodoService.java new file mode 100644 index 00000000000..c837ab8398a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot4; + +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-4-opentelemetry/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/application.properties index 6b57706019b..a0808e04fde 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/application.properties @@ -20,6 +20,9 @@ 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 +sentry.enable-cache-tracing=true +spring.cache.cache-names=todos +spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s # Uncomment and set to true to enable aot compatibility # This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan) diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt new file mode 100644 index 00000000000..b45e9c10853 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-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.evict") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts index 4f3d64524fd..01e07fc2526 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -58,6 +58,10 @@ dependencies { implementation(projects.sentryAsyncProfiler) implementation(projects.sentryOpentelemetry.sentryOpentelemetryOtlpSpring) + // cache tracing + implementation(libs.springboot4.starter.cache) + implementation(libs.caffeine) + // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CacheController.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CacheController.java new file mode 100644 index 00000000000..f453f81187c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot4.otlp; + +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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryDemoApplication.java index b4c58c4882e..1bf622154d0 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryDemoApplication.java @@ -12,6 +12,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.restclient.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; @@ -22,6 +23,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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/TodoService.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/TodoService.java new file mode 100644 index 00000000000..1a748a165a4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot4.otlp; + +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-4-otlp/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/application.properties index 43c0bd18c08..f9b35099062 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/application.properties @@ -20,6 +20,9 @@ sentry.logs.enabled=true sentry.profile-session-sample-rate=1.0 sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces sentry.profile-lifecycle=TRACE +sentry.enable-cache-tracing=true +spring.cache.cache-names=todos +spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s # Uncomment and set to true to enable aot compatibility # This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan) diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt new file mode 100644 index 00000000000..b45e9c10853 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/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.evict") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts index b9986a31d02..cdcf65711a8 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts @@ -32,6 +32,10 @@ dependencies { implementation(libs.springboot4.starter.webflux) implementation(libs.springboot4.starter.webclient) + // cache tracing + implementation(libs.springboot4.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-4-webflux/src/main/java/io/sentry/samples/spring/boot4/CacheController.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/CacheController.java new file mode 100644 index 00000000000..3c2e66442de --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot4; + +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-4-webflux/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java index 72980871730..0d37be7634c 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/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-4-webflux/src/main/java/io/sentry/samples/spring/boot4/TodoService.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/TodoService.java new file mode 100644 index 00000000000..c837ab8398a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot4; + +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-4-webflux/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/resources/application.properties index 9e9d6596e08..9fc969efd28 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/resources/application.properties @@ -15,3 +15,6 @@ sentry.enable-spotlight=true sentry.profile-session-sample-rate=1.0 sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces sentry.profile-lifecycle=TRACE +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-4-webflux/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt new file mode 100644 index 00000000000..b45e9c10853 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/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.evict") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index a7fa57dac83..f43cc47cc6d 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -57,6 +57,10 @@ dependencies { implementation(projects.sentryQuartz) implementation(projects.sentryAsyncProfiler) + // cache tracing + implementation(libs.springboot4.starter.cache) + implementation(libs.caffeine) + // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/CacheController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/CacheController.java new file mode 100644 index 00000000000..3c2e66442de --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot4; + +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-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java index 71463a9a819..13d97fa8442 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java @@ -9,6 +9,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.restclient.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-4/src/main/java/io/sentry/samples/spring/boot4/TodoService.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/TodoService.java new file mode 100644 index 00000000000..c837ab8398a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot4; + +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-4/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application.properties index 9ba7a54aaf8..8198059343a 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application.properties @@ -20,6 +20,9 @@ sentry.logs.enabled=true sentry.profile-session-sample-rate=1.0 sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces sentry.profile-lifecycle=TRACE +sentry.enable-cache-tracing=true +spring.cache.cache-names=todos +spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s # Uncomment and set to true to enable aot compatibility # This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan) diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt new file mode 100644 index 00000000000..b45e9c10853 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/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.evict") + } + } +} 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..b45e9c10853 --- /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.evict") + } + } +} 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..b45e9c10853 --- /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.evict") + } + } +} 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..b45e9c10853 --- /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.evict") + } + } +} 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..b45e9c10853 --- /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.evict") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index be2b4583fb6..b6fcd675cf3 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -40,7 +40,9 @@ dependencies { implementation(libs.springboot.starter.security) implementation(libs.springboot.starter.web) implementation(libs.springboot.starter.webflux) + implementation(libs.springboot.starter.cache) implementation(libs.springboot.starter.websocket) + implementation(libs.caffeine) implementation(Config.Libs.aspectj) implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/CacheController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/CacheController.java new file mode 100644 index 00000000000..e85f201139f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot; + +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/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java index b4f46260997..a08770b1029 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/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; @@ -18,6 +19,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/src/main/java/io/sentry/samples/spring/boot/TodoService.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/TodoService.java new file mode 100644 index 00000000000..81aa944c8be --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot; + +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/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties index d39f38d7182..4e97e7a1eb8 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties @@ -20,6 +20,11 @@ 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 + # Database configuration spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt new file mode 100644 index 00000000000..b45e9c10853 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/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.evict") + } + } +} diff --git a/sentry-spring-7/api/sentry-spring-7.api b/sentry-spring-7/api/sentry-spring-7.api index 3a57c13e835..71a8a022bf6 100644 --- a/sentry-spring-7/api/sentry-spring-7.api +++ b/sentry-spring-7/api/sentry-spring-7.api @@ -104,6 +104,35 @@ public final class io/sentry/spring7/SpringSecuritySentryUserProvider : io/sentr public fun provideUser ()Lio/sentry/protocol/User; } +public final class io/sentry/spring7/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/spring7/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/spring7/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/spring7/checkin/SentryCheckIn : java/lang/annotation/Annotation { public abstract fun heartbeat ()Z public abstract fun monitorSlug ()Ljava/lang/String; diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheBeanPostProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheBeanPostProcessor.java new file mode 100644 index 00000000000..b6569a9953b --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheBeanPostProcessor.java @@ -0,0 +1,29 @@ +package io.sentry.spring7.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-7/src/main/java/io/sentry/spring7/cache/SentryCacheManagerWrapper.java b/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheManagerWrapper.java new file mode 100644 index 00000000000..97ac313bd91 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheManagerWrapper.java @@ -0,0 +1,37 @@ +package io.sentry.spring7.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-7/src/main/java/io/sentry/spring7/cache/SentryCacheWrapper.java b/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheWrapper.java new file mode 100644 index 00000000000..e5cb9ce87e9 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheWrapper.java @@ -0,0 +1,326 @@ +package io.sentry.spring7.cache; + +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import java.util.Collections; +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(key, "get"); + if (span == null) { + return delegate.get(key); + } + try { + final ValueWrapper result = delegate.get(key); + span.setData(SpanDataConvention.CACHE_HIT, 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(key, "get"); + if (span == null) { + return delegate.get(key, type); + } + try { + final T result = delegate.get(key, type); + span.setData(SpanDataConvention.CACHE_HIT, 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(key, "get"); + 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, !loaderInvoked.get()); + span.setData(SpanDataConvention.CACHE_WRITE, 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(key, "retrieve"); + 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, 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, value != null); + span.setStatus(SpanStatus.OK); + } + span.finish(); + }); + } + + @Override + public CompletableFuture retrieve( + final @NotNull Object key, final @NotNull Supplier> valueLoader) { + final ISpan span = startSpan(key, "retrieve"); + 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, !loaderInvoked.get()); + span.setData(SpanDataConvention.CACHE_WRITE, loaderInvoked.get()); + span.setStatus(SpanStatus.OK); + } + span.finish(); + }); + } + + @Override + public void put(final @NotNull Object key, final @Nullable Object value) { + final ISpan span = startSpan(key, "put"); + if (span == null) { + delegate.put(key, value); + return; + } + try { + delegate.put(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public @Nullable ValueWrapper putIfAbsent( + final @NotNull Object key, final @Nullable Object value) { + final ISpan span = startSpan(key, "putIfAbsent"); + if (span == null) { + return delegate.putIfAbsent(key, value); + } + try { + final ValueWrapper result = delegate.putIfAbsent(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, 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 void evict(final @NotNull Object key) { + final ISpan span = startSpan(key, "evict"); + if (span == null) { + delegate.evict(key); + return; + } + try { + delegate.evict(key); + span.setData(SpanDataConvention.CACHE_WRITE, true); + 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(key, "evictIfPresent"); + if (span == null) { + return delegate.evictIfPresent(key); + } + try { + final boolean result = delegate.evictIfPresent(key); + span.setData(SpanDataConvention.CACHE_WRITE, result); + 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(null, "clear"); + if (span == null) { + delegate.clear(); + return; + } + try { + delegate.clear(); + span.setData(SpanDataConvention.CACHE_WRITE, true); + 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(null, "invalidate"); + if (span == null) { + return delegate.invalidate(); + } + try { + final boolean result = delegate.invalidate(); + span.setData(SpanDataConvention.CACHE_WRITE, result); + 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 @Nullable Object key, final @NotNull String operationName) { + 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("cache." + operationName, keyString, spanOptions); + if (span.isNoOp()) { + return null; + } + if (keyString != null) { + span.setData(SpanDataConvention.CACHE_KEY, Collections.singletonList(keyString)); + } + span.setData(SpanDataConvention.CACHE_OPERATION, operationName); + return span; + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheBeanPostProcessorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheBeanPostProcessorTest.kt new file mode 100644 index 00000000000..54c5e696d6a --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheBeanPostProcessorTest.kt @@ -0,0 +1,44 @@ +package io.sentry.spring7.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-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheManagerWrapperTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheManagerWrapperTest.kt new file mode 100644 index 00000000000..dbc5992b7e0 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheManagerWrapperTest.kt @@ -0,0 +1,61 @@ +package io.sentry.spring7.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-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheWrapperTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheWrapperTest.kt new file mode 100644 index 00000000000..ef056e7fdb5 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheWrapperTest.kt @@ -0,0 +1,530 @@ +package io.sentry.spring7.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.assertFalse +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)) + assertNull(span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("auto.cache.spring", span.spanContext.origin) + assertEquals("get", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + @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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_KEY)) + } + + @Test + fun `get with type sets error status and throwable on exception`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val exception = RuntimeException("cache error") + whenever(delegate.get("myKey", String::class.java)).thenThrow(exception) + + assertFailsWith { wrapper.get("myKey", String::class.java) } + + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } + + // -- 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)) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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.retrieve", span.operation) + assertEquals("myKey", span.description) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_HIT)) + assertNull(span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("retrieve", span.getData(SpanDataConvention.CACHE_OPERATION)) + 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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_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)) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("put", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- putIfAbsent -- + + @Test + fun `putIfAbsent creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.putIfAbsent("myKey", "myValue")).thenReturn(null) + + val result = wrapper.putIfAbsent("myKey", "myValue") + + assertNull(result) + verify(delegate).putIfAbsent("myKey", "myValue") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.putIfAbsent", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("putIfAbsent", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- 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.evict", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("evict", span.getData(SpanDataConvention.CACHE_OPERATION)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + } + + // -- 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.evictIfPresent", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("evictIfPresent", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_KEY)) + } + + // -- 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.clear", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertNull(span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("clear", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- 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.invalidate", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("invalidate", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + @Test + fun `invalidate sets cache write false when cache had no mappings`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.invalidate()).thenReturn(false) + + val result = wrapper.invalidate() + + assertFalse(result) + assertEquals(1, tx.spans.size) + assertEquals("cache.invalidate", tx.spans.first().operation) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("invalidate", tx.spans.first().getData(SpanDataConvention.CACHE_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) + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java index 1b804e8cb8d..ae9e3ac50fe 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java @@ -25,6 +25,7 @@ import io.sentry.spring7.SentryWebConfiguration; import io.sentry.spring7.SpringProfilesEventProcessor; import io.sentry.spring7.SpringSecuritySentryUserProvider; +import io.sentry.spring7.cache.SentryCacheBeanPostProcessor; import io.sentry.spring7.checkin.SentryCheckInAdviceConfiguration; import io.sentry.spring7.checkin.SentryCheckInPointcutConfiguration; import io.sentry.spring7.checkin.SentryQuartzConfiguration; @@ -65,6 +66,7 @@ import org.springframework.boot.restclient.autoconfigure.RestTemplateAutoConfiguration; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration; +import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @@ -229,6 +231,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-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt index a5566ef2f30..7f30c860bb3 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -38,6 +38,7 @@ import io.sentry.spring7.SentryUserFilter import io.sentry.spring7.SentryUserProvider import io.sentry.spring7.SpringProfilesEventProcessor import io.sentry.spring7.SpringSecuritySentryUserProvider +import io.sentry.spring7.cache.SentryCacheBeanPostProcessor import io.sentry.spring7.tracing.SentryTracingFilter import io.sentry.spring7.tracing.SpringServletTransactionNameProvider import io.sentry.spring7.tracing.TransactionNameProvider @@ -231,6 +232,7 @@ class SentryAutoConfigurationTest { "sentry.ignored-transactions=transactionName1,transactionNameB", "sentry.enable-backpressure-handling=false", "sentry.enable-database-transaction-tracing=true", + "sentry.enable-cache-tracing=true", "sentry.enable-spotlight=true", "sentry.spotlight-connection-url=http://local.sentry.io:1234", "sentry.force-init=true", @@ -284,6 +286,7 @@ class SentryAutoConfigurationTest { .containsOnly(FilterString("transactionName1"), FilterString("transactionNameB")) assertThat(options.isEnableBackpressureHandling).isEqualTo(false) assertThat(options.isEnableDatabaseTransactionTracing).isEqualTo(true) + assertThat(options.isEnableCacheTracing).isEqualTo(true) assertThat(options.isForceInit).isEqualTo(true) assertThat(options.isGlobalHubMode).isEqualTo(true) assertThat(options.isCaptureOpenTelemetryEvents).isEqualTo(true) @@ -1179,6 +1182,33 @@ class SentryAutoConfigurationTest { } } + @Test + fun `SentryCacheBeanPostProcessor is registered when enable-cache-tracing is true`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.enable-cache-tracing=true", + ) + .run { assertThat(it).hasSingleBean(SentryCacheBeanPostProcessor::class.java) } + } + + @Test + fun `SentryCacheBeanPostProcessor is not registered when enable-cache-tracing is missing`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).doesNotHaveBean(SentryCacheBeanPostProcessor::class.java) + } + } + + @Test + fun `SentryCacheBeanPostProcessor is not registered when enable-cache-tracing is false`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.enable-cache-tracing=false", + ) + .run { assertThat(it).doesNotHaveBean(SentryCacheBeanPostProcessor::class.java) } + } + @Configuration(proxyBeanMethods = false) open class CustomSchedulerFactoryBeanCustomizerConfiguration { class MyJobListener : JobListener { 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-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index 76424b5c55f..99fd602f74b 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -25,6 +25,7 @@ import io.sentry.spring.SpringProfilesEventProcessor; import io.sentry.spring.SpringSecuritySentryUserProvider; import io.sentry.spring.boot.graphql.SentryGraphqlAutoConfiguration; +import io.sentry.spring.cache.SentryCacheBeanPostProcessor; import io.sentry.spring.checkin.SentryCheckInAdviceConfiguration; import io.sentry.spring.checkin.SentryCheckInPointcutConfiguration; import io.sentry.spring.checkin.SentryQuartzConfiguration; @@ -64,6 +65,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; @@ -216,6 +218,19 @@ static class GraphqlConfiguration {} }) 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..9b7c551a2d2 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/cache/SentryCacheWrapper.java @@ -0,0 +1,326 @@ +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.Collections; +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(key, "get"); + if (span == null) { + return delegate.get(key); + } + try { + final ValueWrapper result = delegate.get(key); + span.setData(SpanDataConvention.CACHE_HIT, 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(key, "get"); + if (span == null) { + return delegate.get(key, type); + } + try { + final T result = delegate.get(key, type); + span.setData(SpanDataConvention.CACHE_HIT, 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(key, "get"); + 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, !loaderInvoked.get()); + span.setData(SpanDataConvention.CACHE_WRITE, 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(key, "retrieve"); + 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, 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, value != null); + span.setStatus(SpanStatus.OK); + } + span.finish(); + }); + } + + @Override + public CompletableFuture retrieve( + final @NotNull Object key, final @NotNull Supplier> valueLoader) { + final ISpan span = startSpan(key, "retrieve"); + 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, !loaderInvoked.get()); + span.setData(SpanDataConvention.CACHE_WRITE, loaderInvoked.get()); + span.setStatus(SpanStatus.OK); + } + span.finish(); + }); + } + + @Override + public void put(final @NotNull Object key, final @Nullable Object value) { + final ISpan span = startSpan(key, "put"); + if (span == null) { + delegate.put(key, value); + return; + } + try { + delegate.put(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public @Nullable ValueWrapper putIfAbsent( + final @NotNull Object key, final @Nullable Object value) { + final ISpan span = startSpan(key, "putIfAbsent"); + if (span == null) { + return delegate.putIfAbsent(key, value); + } + try { + final ValueWrapper result = delegate.putIfAbsent(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, 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 void evict(final @NotNull Object key) { + final ISpan span = startSpan(key, "evict"); + if (span == null) { + delegate.evict(key); + return; + } + try { + delegate.evict(key); + span.setData(SpanDataConvention.CACHE_WRITE, true); + 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(key, "evictIfPresent"); + if (span == null) { + return delegate.evictIfPresent(key); + } + try { + final boolean result = delegate.evictIfPresent(key); + span.setData(SpanDataConvention.CACHE_WRITE, result); + 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(null, "clear"); + if (span == null) { + delegate.clear(); + return; + } + try { + delegate.clear(); + span.setData(SpanDataConvention.CACHE_WRITE, true); + 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(null, "invalidate"); + if (span == null) { + return delegate.invalidate(); + } + try { + final boolean result = delegate.invalidate(); + span.setData(SpanDataConvention.CACHE_WRITE, result); + 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 @Nullable Object key, final @NotNull String operationName) { + 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("cache." + operationName, keyString, spanOptions); + if (span.isNoOp()) { + return null; + } + if (keyString != null) { + span.setData(SpanDataConvention.CACHE_KEY, Collections.singletonList(keyString)); + } + span.setData(SpanDataConvention.CACHE_OPERATION, operationName); + 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..a04f548d447 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/cache/SentryCacheWrapperTest.kt @@ -0,0 +1,530 @@ +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.assertFalse +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)) + assertNull(span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("auto.cache.spring", span.spanContext.origin) + assertEquals("get", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + @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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_KEY)) + } + + @Test + fun `get with type sets error status and throwable on exception`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val exception = RuntimeException("cache error") + whenever(delegate.get("myKey", String::class.java)).thenThrow(exception) + + assertFailsWith { wrapper.get("myKey", String::class.java) } + + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } + + // -- 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)) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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.retrieve", span.operation) + assertEquals("myKey", span.description) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_HIT)) + assertNull(span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("retrieve", span.getData(SpanDataConvention.CACHE_OPERATION)) + 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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_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)) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("put", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- putIfAbsent -- + + @Test + fun `putIfAbsent creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.putIfAbsent("myKey", "myValue")).thenReturn(null) + + val result = wrapper.putIfAbsent("myKey", "myValue") + + assertNull(result) + verify(delegate).putIfAbsent("myKey", "myValue") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.putIfAbsent", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("putIfAbsent", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- 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.evict", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("evict", span.getData(SpanDataConvention.CACHE_OPERATION)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + } + + // -- 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.evictIfPresent", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("evictIfPresent", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_KEY)) + } + + // -- 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.clear", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertNull(span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("clear", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- 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.invalidate", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("invalidate", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + @Test + fun `invalidate sets cache write false when cache had no mappings`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.invalidate()).thenReturn(false) + + val result = wrapper.invalidate() + + assertFalse(result) + assertEquals(1, tx.spans.size) + assertEquals("cache.invalidate", tx.spans.first().operation) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("invalidate", tx.spans.first().getData(SpanDataConvention.CACHE_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) + } +} diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index fb07af382ba..7148277e2ef 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -104,6 +104,33 @@ public final class io/sentry/spring/SpringSecuritySentryUserProvider : io/sentry public fun provideUser ()Lio/sentry/protocol/User; } +public final class io/sentry/spring/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/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/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 abstract interface annotation class io/sentry/spring/checkin/SentryCheckIn : java/lang/annotation/Annotation { public abstract fun heartbeat ()Z public abstract fun monitorSlug ()Ljava/lang/String; diff --git a/sentry-spring/src/main/java/io/sentry/spring/cache/SentryCacheBeanPostProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/cache/SentryCacheBeanPostProcessor.java new file mode 100644 index 00000000000..7382f7500f2 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/cache/SentryCacheBeanPostProcessor.java @@ -0,0 +1,29 @@ +package io.sentry.spring.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/src/main/java/io/sentry/spring/cache/SentryCacheManagerWrapper.java b/sentry-spring/src/main/java/io/sentry/spring/cache/SentryCacheManagerWrapper.java new file mode 100644 index 00000000000..a66517fd7fb --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/cache/SentryCacheManagerWrapper.java @@ -0,0 +1,37 @@ +package io.sentry.spring.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/src/main/java/io/sentry/spring/cache/SentryCacheWrapper.java b/sentry-spring/src/main/java/io/sentry/spring/cache/SentryCacheWrapper.java new file mode 100644 index 00000000000..0e0ccb7d228 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/cache/SentryCacheWrapper.java @@ -0,0 +1,253 @@ +package io.sentry.spring.cache; + +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import java.util.Collections; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +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(key, "get"); + if (span == null) { + return delegate.get(key); + } + try { + final ValueWrapper result = delegate.get(key); + span.setData(SpanDataConvention.CACHE_HIT, 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(key, "get"); + if (span == null) { + return delegate.get(key, type); + } + try { + final T result = delegate.get(key, type); + span.setData(SpanDataConvention.CACHE_HIT, 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(key, "get"); + 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, !loaderInvoked.get()); + span.setData(SpanDataConvention.CACHE_WRITE, 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 void put(final @NotNull Object key, final @Nullable Object value) { + final ISpan span = startSpan(key, "put"); + if (span == null) { + delegate.put(key, value); + return; + } + try { + delegate.put(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, true); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public @Nullable ValueWrapper putIfAbsent( + final @NotNull Object key, final @Nullable Object value) { + final ISpan span = startSpan(key, "putIfAbsent"); + if (span == null) { + return delegate.putIfAbsent(key, value); + } + try { + final ValueWrapper result = delegate.putIfAbsent(key, value); + span.setData(SpanDataConvention.CACHE_WRITE, 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 void evict(final @NotNull Object key) { + final ISpan span = startSpan(key, "evict"); + if (span == null) { + delegate.evict(key); + return; + } + try { + delegate.evict(key); + span.setData(SpanDataConvention.CACHE_WRITE, true); + 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(key, "evictIfPresent"); + if (span == null) { + return delegate.evictIfPresent(key); + } + try { + final boolean result = delegate.evictIfPresent(key); + span.setData(SpanDataConvention.CACHE_WRITE, result); + 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(null, "clear"); + if (span == null) { + delegate.clear(); + return; + } + try { + delegate.clear(); + span.setData(SpanDataConvention.CACHE_WRITE, true); + 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(null, "invalidate"); + if (span == null) { + return delegate.invalidate(); + } + try { + final boolean result = delegate.invalidate(); + span.setData(SpanDataConvention.CACHE_WRITE, result); + 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 @Nullable Object key, final @NotNull String operationName) { + 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("cache." + operationName, keyString, spanOptions); + if (span.isNoOp()) { + return null; + } + if (keyString != null) { + span.setData(SpanDataConvention.CACHE_KEY, Collections.singletonList(keyString)); + } + span.setData(SpanDataConvention.CACHE_OPERATION, operationName); + return span; + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/cache/SentryCacheBeanPostProcessorTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/cache/SentryCacheBeanPostProcessorTest.kt new file mode 100644 index 00000000000..4392d6820e5 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/cache/SentryCacheBeanPostProcessorTest.kt @@ -0,0 +1,44 @@ +package io.sentry.spring.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/src/test/kotlin/io/sentry/spring/cache/SentryCacheManagerWrapperTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/cache/SentryCacheManagerWrapperTest.kt new file mode 100644 index 00000000000..e3d45038732 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/cache/SentryCacheManagerWrapperTest.kt @@ -0,0 +1,61 @@ +package io.sentry.spring.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/src/test/kotlin/io/sentry/spring/cache/SentryCacheWrapperTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/cache/SentryCacheWrapperTest.kt new file mode 100644 index 00000000000..ab21ef77b4f --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/cache/SentryCacheWrapperTest.kt @@ -0,0 +1,354 @@ +package io.sentry.spring.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 kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +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)) + assertNull(span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("auto.cache.spring", span.spanContext.origin) + assertEquals("get", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + @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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_KEY)) + } + + @Test + fun `get with type sets error status and throwable on exception`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val exception = RuntimeException("cache error") + whenever(delegate.get("myKey", String::class.java)).thenThrow(exception) + + assertFailsWith { wrapper.get("myKey", String::class.java) } + + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } + + // -- 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)) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_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)) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_KEY)) + } + + // -- 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(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("put", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- putIfAbsent -- + + @Test + fun `putIfAbsent creates cache put span`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.putIfAbsent("myKey", "myValue")).thenReturn(null) + + val result = wrapper.putIfAbsent("myKey", "myValue") + + assertNull(result) + verify(delegate).putIfAbsent("myKey", "myValue") + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.putIfAbsent", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("putIfAbsent", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- 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.evict", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("evict", span.getData(SpanDataConvention.CACHE_OPERATION)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY)) + } + + // -- 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.evictIfPresent", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("evictIfPresent", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + assertEquals(listOf("myKey"), tx.spans.first().getData(SpanDataConvention.CACHE_KEY)) + } + + // -- 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.clear", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_WRITE)) + assertNull(span.getData(SpanDataConvention.CACHE_KEY)) + assertEquals("clear", span.getData(SpanDataConvention.CACHE_OPERATION)) + } + + // -- 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.invalidate", tx.spans.first().operation) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("invalidate", tx.spans.first().getData(SpanDataConvention.CACHE_OPERATION)) + } + + @Test + fun `invalidate sets cache write false when cache had no mappings`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.invalidate()).thenReturn(false) + + val result = wrapper.invalidate() + + assertFalse(result) + assertEquals(1, tx.spans.size) + assertEquals("cache.invalidate", tx.spans.first().operation) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_WRITE)) + assertEquals("invalidate", tx.spans.first().getData(SpanDataConvention.CACHE_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) + } +} diff --git a/sentry-system-test-support/api/sentry-system-test-support.api b/sentry-system-test-support/api/sentry-system-test-support.api index 51ef7da55d9..83a9f288d0c 100644 --- a/sentry-system-test-support/api/sentry-system-test-support.api +++ b/sentry-system-test-support/api/sentry-system-test-support.api @@ -548,7 +548,9 @@ public final class io/sentry/systemtest/util/RestTestClient : io/sentry/systemte public static synthetic fun createPerson$default (Lio/sentry/systemtest/util/RestTestClient;Lio/sentry/systemtest/Person;Ljava/util/Map;ILjava/lang/Object;)Lio/sentry/systemtest/Person; public final fun createPersonDistributedTracing (Lio/sentry/systemtest/Person;Ljava/util/Map;)Lio/sentry/systemtest/Person; public static synthetic fun createPersonDistributedTracing$default (Lio/sentry/systemtest/util/RestTestClient;Lio/sentry/systemtest/Person;Ljava/util/Map;ILjava/lang/Object;)Lio/sentry/systemtest/Person; + public final fun deleteCachedTodo (J)V public final fun errorWithFeatureFlag (Ljava/lang/String;)Ljava/lang/String; + public final fun getCachedTodo (J)Lio/sentry/systemtest/Todo; public final fun getCountMetric ()Ljava/lang/String; public final fun getDistributionMetric (J)Ljava/lang/String; public final fun getGaugeMetric (J)Ljava/lang/String; @@ -558,6 +560,7 @@ public final class io/sentry/systemtest/util/RestTestClient : io/sentry/systemte public final fun getTodo (J)Lio/sentry/systemtest/Todo; public final fun getTodoRestClient (J)Lio/sentry/systemtest/Todo; public final fun getTodoWebclient (J)Lio/sentry/systemtest/Todo; + public final fun saveCachedTodo (Lio/sentry/systemtest/Todo;)Lio/sentry/systemtest/Todo; } public final class io/sentry/systemtest/util/SentryMockServerClient : io/sentry/systemtest/util/LoggingInsecureRestClient { diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt index bdaa2333f21..da552ff93bc 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt @@ -50,6 +50,24 @@ class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestCl return callTyped(request, true) } + fun getCachedTodo(id: Long): Todo? { + val request = Request.Builder().url("$backendBaseUrl/cache/$id") + + return callTyped(request, true) + } + + fun saveCachedTodo(todo: Todo): Todo? { + val request = Request.Builder().url("$backendBaseUrl/cache/").post(toRequestBody(todo)) + + return callTyped(request, true) + } + + fun deleteCachedTodo(id: Long) { + val request = Request.Builder().url("$backendBaseUrl/cache/$id").delete() + + call(request, true) + } + fun checkFeatureFlag(flagKey: String): FeatureFlagResponse? { val request = Request.Builder().url("$backendBaseUrl/feature-flag/check/$flagKey") diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 1d8ff4d3e0d..c748df38369 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -520,6 +520,7 @@ public final class io/sentry/ExternalOptions { public fun getTracesSampleRate ()Ljava/lang/Double; public fun isCaptureOpenTelemetryEvents ()Ljava/lang/Boolean; public fun isEnableBackpressureHandling ()Ljava/lang/Boolean; + public fun isEnableCacheTracing ()Ljava/lang/Boolean; public fun isEnableDatabaseTransactionTracing ()Ljava/lang/Boolean; public fun isEnableLogs ()Ljava/lang/Boolean; public fun isEnableMetrics ()Ljava/lang/Boolean; @@ -536,6 +537,7 @@ public final class io/sentry/ExternalOptions { public fun setDist (Ljava/lang/String;)V public fun setDsn (Ljava/lang/String;)V public fun setEnableBackpressureHandling (Ljava/lang/Boolean;)V + public fun setEnableCacheTracing (Ljava/lang/Boolean;)V public fun setEnableDatabaseTransactionTracing (Ljava/lang/Boolean;)V public fun setEnableDeduplication (Ljava/lang/Boolean;)V public fun setEnableLogs (Ljava/lang/Boolean;)V @@ -3667,6 +3669,7 @@ public class io/sentry/SentryOptions { public fun isEnableAppStartProfiling ()Z public fun isEnableAutoSessionTracking ()Z public fun isEnableBackpressureHandling ()Z + public fun isEnableCacheTracing ()Z public fun isEnableDatabaseTransactionTracing ()Z public fun isEnableDeduplication ()Z public fun isEnableEventSizeLimiting ()Z @@ -3725,6 +3728,7 @@ public class io/sentry/SentryOptions { public fun setEnableAppStartProfiling (Z)V public fun setEnableAutoSessionTracking (Z)V public fun setEnableBackpressureHandling (Z)V + public fun setEnableCacheTracing (Z)V public fun setEnableDatabaseTransactionTracing (Z)V public fun setEnableDeduplication (Z)V public fun setEnableEventSizeLimiting (Z)V @@ -4352,6 +4356,10 @@ public final class io/sentry/SpanContext$JsonKeys { public abstract interface class io/sentry/SpanDataConvention { public static final field BLOCKED_MAIN_THREAD_KEY Ljava/lang/String; + public static final field CACHE_HIT Ljava/lang/String; + public static final field CACHE_KEY Ljava/lang/String; + public static final field CACHE_OPERATION Ljava/lang/String; + public static final field CACHE_WRITE Ljava/lang/String; public static final field CALL_STACK_KEY Ljava/lang/String; public static final field CONTRIBUTES_TTFD Ljava/lang/String; public static final field CONTRIBUTES_TTID Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 8f16bcede01..dade1f140c8 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -57,6 +57,7 @@ public final class ExternalOptions { private @Nullable Boolean sendDefaultPii; private @Nullable Boolean enableBackpressureHandling; private @Nullable Boolean enableDatabaseTransactionTracing; + private @Nullable Boolean enableCacheTracing; private @Nullable Boolean globalHubMode; private @Nullable Boolean forceInit; private @Nullable Boolean captureOpenTelemetryEvents; @@ -162,6 +163,8 @@ public final class ExternalOptions { options.setEnableDatabaseTransactionTracing( propertiesProvider.getBooleanProperty("enable-database-transaction-tracing")); + options.setEnableCacheTracing(propertiesProvider.getBooleanProperty("enable-cache-tracing")); + options.setGlobalHubMode(propertiesProvider.getBooleanProperty("global-hub-mode")); options.setCaptureOpenTelemetryEvents( @@ -523,6 +526,14 @@ public void setEnableDatabaseTransactionTracing( return enableDatabaseTransactionTracing; } + public void setEnableCacheTracing(final @Nullable Boolean enableCacheTracing) { + this.enableCacheTracing = enableCacheTracing; + } + + public @Nullable Boolean isEnableCacheTracing() { + return enableCacheTracing; + } + public void setGlobalHubMode(final @Nullable Boolean globalHubMode) { this.globalHubMode = globalHubMode; } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 862bd708aa4..9df125b4d11 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -490,6 +490,9 @@ public class SentryOptions { /** Whether database transaction spans (BEGIN, COMMIT, ROLLBACK) should be traced. */ private boolean enableDatabaseTransactionTracing = false; + /** Whether cache operations (get, put, remove, flush) should be traced. */ + private boolean enableCacheTracing = false; + /** Date provider to retrieve the current date from. */ @ApiStatus.Internal private final @NotNull LazyEvaluator dateProvider = @@ -2632,6 +2635,24 @@ public void setEnableDatabaseTransactionTracing(boolean enableDatabaseTransactio this.enableDatabaseTransactionTracing = enableDatabaseTransactionTracing; } + /** + * Whether cache operations (get, put, remove, flush) should be traced. + * + * @return true if cache operations should be traced + */ + public boolean isEnableCacheTracing() { + return enableCacheTracing; + } + + /** + * Whether cache operations (get, put, remove, flush) should be traced. + * + * @param enableCacheTracing true if cache operations should be traced + */ + public void setEnableCacheTracing(boolean enableCacheTracing) { + this.enableCacheTracing = enableCacheTracing; + } + /** * Whether Sentry is enabled. * @@ -3470,6 +3491,9 @@ public void merge(final @NotNull ExternalOptions options) { if (options.isEnableDatabaseTransactionTracing() != null) { setEnableDatabaseTransactionTracing(options.isEnableDatabaseTransactionTracing()); } + if (options.isEnableCacheTracing() != null) { + setEnableCacheTracing(options.isEnableCacheTracing()); + } if (options.getMaxRequestBodySize() != null) { setMaxRequestBodySize(options.getMaxRequestBodySize()); } diff --git a/sentry/src/main/java/io/sentry/SpanDataConvention.java b/sentry/src/main/java/io/sentry/SpanDataConvention.java index c4329f6dcad..647c0dacddf 100644 --- a/sentry/src/main/java/io/sentry/SpanDataConvention.java +++ b/sentry/src/main/java/io/sentry/SpanDataConvention.java @@ -26,4 +26,8 @@ public interface SpanDataConvention { String HTTP_START_TIMESTAMP = "http.start_timestamp"; String HTTP_END_TIMESTAMP = "http.end_timestamp"; String PROFILER_ID = "profiler_id"; + String CACHE_HIT = "cache.hit"; + String CACHE_KEY = "cache.key"; + String CACHE_OPERATION = "cache.operation"; + String CACHE_WRITE = "cache.write"; } diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 9612a052624..298eff34ba0 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -331,6 +331,20 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with enableCacheTracing set to true`() { + withPropertiesFile("enable-cache-tracing=true") { options -> + assertTrue(options.isEnableCacheTracing == true) + } + } + + @Test + fun `creates options with enableCacheTracing set to false`() { + withPropertiesFile("enable-cache-tracing=false") { options -> + assertTrue(options.isEnableCacheTracing == false) + } + } + @Test fun `creates options with cron defaults`() { withPropertiesFile( diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 1fd8d9cc81f..1b9ce5eace3 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -400,6 +400,7 @@ class SentryOptionsTest { externalOptions.ignoredErrors = listOf("Some error", "Another .*") externalOptions.isEnableBackpressureHandling = false externalOptions.isEnableDatabaseTransactionTracing = true + externalOptions.isEnableCacheTracing = true externalOptions.maxRequestBodySize = SentryOptions.RequestSize.MEDIUM externalOptions.isSendDefaultPii = true externalOptions.isForceInit = true @@ -465,6 +466,7 @@ class SentryOptionsTest { ) assertFalse(options.isEnableBackpressureHandling) assertTrue(options.isEnableDatabaseTransactionTracing) + assertTrue(options.isEnableCacheTracing) assertTrue(options.isForceInit) assertNotNull(options.cron) assertEquals(10L, options.cron?.defaultCheckinMargin) @@ -701,6 +703,11 @@ class SentryOptionsTest { assertFalse(SentryOptions().isEnableDatabaseTransactionTracing) } + @Test + fun `when options are initialized, enableCacheTracing is set to false by default`() { + assertFalse(SentryOptions().isEnableCacheTracing) + } + @Test fun `when options are initialized, metrics is enabled by default`() { assertTrue(SentryOptions().metrics.isEnabled) diff --git a/settings.gradle.kts b/settings.gradle.kts index 0e9987b4ae4..8d431d5fbdf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -66,6 +66,7 @@ include( "sentry-opentelemetry:sentry-opentelemetry-agentless-spring", "sentry-opentelemetry:sentry-opentelemetry-otlp", "sentry-opentelemetry:sentry-opentelemetry-otlp-spring", + "sentry-jcache", "sentry-quartz", "sentry-okhttp", "sentry-openfeature", diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 55a1136fbe0..70489c580a5 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -61,7 +61,8 @@ "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", "OTEL_LOGS_EXPORTER": "none", - "SENTRY_LOGS_ENABLED": "true" + "SENTRY_LOGS_ENABLED": "true", + "SENTRY_ENABLE_CACHE_TRACING": "true" } class ServerType(Enum):