diff --git a/CHANGELOG.md b/CHANGELOG.md index ef46ab18c66..1237e6e07fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - Add cache tracing instrumentation for Spring Boot 4 ([#5172](https://github.com/getsentry/sentry-java/pull/5172), [#5173](https://github.com/getsentry/sentry-java/pull/5173), [#5174](https://github.com/getsentry/sentry-java/pull/5174)) - Wraps Spring `CacheManager` and `Cache` beans to produce `cache.get`, `cache.put`, `cache.remove`, and `cache.flush` spans - Set `sentry.enable-cache-tracing` to `true` to enable this feature +- Add JCache (JSR-107) cache tracing via new `sentry-jcache` module ([#5179](https://github.com/getsentry/sentry-java/pull/5179)) + - Wraps JCache `Cache` with `SentryJCacheWrapper` to produce `cache.get`, `cache.put`, `cache.remove`, and `cache.flush` spans + - Set `sentry.enable-cache-tracing` to `true` to enable this feature ## 8.34.1 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 e1bee1c8eba..1e57b961fda 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -145,6 +145,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.0" } +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" } diff --git a/sentry-jcache/README.md b/sentry-jcache/README.md new file mode 100644 index 00000000000..071950a7a2c --- /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/tracing/instrumentation/jcache/). diff --git a/sentry-jcache/api/sentry-jcache.api b/sentry-jcache/api/sentry-jcache.api new file mode 100644 index 00000000000..61f1031267f --- /dev/null +++ b/sentry-jcache/api/sentry-jcache.api @@ -0,0 +1,37 @@ +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;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..61c829726f6 --- /dev/null +++ b/sentry-jcache/src/main/java/io/sentry/jcache/SentryJCacheWrapper.java @@ -0,0 +1,443 @@ +package io.sentry.jcache; + +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import java.util.Arrays; +import java.util.Iterator; +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, final @NotNull IScopes scopes) { + this.delegate = delegate; + this.scopes = scopes; + } + + // -- read operations -- + + @Override + public V get(final K key) { + final ISpan span = startSpan("cache.get", key); + if (span == null) { + return delegate.get(key); + } + try { + final V result = delegate.get(key); + span.setData(SpanDataConvention.CACHE_HIT_KEY, result != null); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public Map getAll(final Set keys) { + final ISpan span = startSpanForKeys("cache.get", keys); + if (span == null) { + return delegate.getAll(keys); + } + try { + final Map result = delegate.getAll(keys); + span.setData(SpanDataConvention.CACHE_HIT_KEY, !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("cache.put", key); + if (span == null) { + delegate.put(key, value); + return; + } + try { + delegate.put(key, value); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public V getAndPut(final K key, final V value) { + final ISpan span = startSpan("cache.put", key); + if (span == null) { + return delegate.getAndPut(key, value); + } + try { + final V result = delegate.getAndPut(key, value); + 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("cache.put", map.keySet()); + if (span == null) { + delegate.putAll(map); + return; + } + try { + delegate.putAll(map); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + // putIfAbsent is not instrumented — we cannot know ahead of time whether the put + // will actually happen, and emitting a cache.put span for a no-op would be misleading. + @Override + public boolean putIfAbsent(final K key, final V value) { + return delegate.putIfAbsent(key, value); + } + + // replace and getAndReplace are not instrumented — like putIfAbsent, they are conditional + // writes (only happen if the key exists / value matches). Emitting a cache.put span for a + // potential no-op would be misleading. + @Override + public boolean replace(final K key, final V oldValue, final V newValue) { + return delegate.replace(key, oldValue, newValue); + } + + @Override + public boolean replace(final K key, final V value) { + return delegate.replace(key, value); + } + + @Override + public V getAndReplace(final K key, final V value) { + return delegate.getAndReplace(key, value); + } + + // -- remove operations -- + + @Override + public boolean remove(final K key) { + final ISpan span = startSpan("cache.remove", key); + if (span == null) { + return delegate.remove(key); + } + try { + final boolean result = delegate.remove(key); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public boolean remove(final K key, final V oldValue) { + final ISpan span = startSpan("cache.remove", key); + if (span == null) { + return delegate.remove(key, oldValue); + } + try { + final boolean result = delegate.remove(key, oldValue); + 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("cache.remove", key); + if (span == null) { + return delegate.getAndRemove(key); + } + try { + final V result = delegate.getAndRemove(key); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public void removeAll(final Set keys) { + final ISpan span = startSpanForKeys("cache.remove", keys); + if (span == null) { + delegate.removeAll(keys); + return; + } + try { + delegate.removeAll(keys); + 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("cache.flush", null); + if (span == null) { + delegate.removeAll(); + return; + } + try { + delegate.removeAll(); + 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("cache.flush", null); + if (span == null) { + delegate.clear(); + return; + } + try { + delegate.clear(); + span.setStatus(SpanStatus.OK); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + + @Override + public 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("cache.get", key); + 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("cache.get", keys); + 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 @NotNull String operation, final @Nullable Object key) { + if (!scopes.getOptions().isEnableCacheTracing()) { + return null; + } + + final ISpan activeSpan = scopes.getSpan(); + if (activeSpan == null || activeSpan.isNoOp()) { + return null; + } + + final SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(TRACE_ORIGIN); + final String keyString = key != null ? String.valueOf(key) : null; + final ISpan span = activeSpan.startChild(operation, keyString, spanOptions); + if (span.isNoOp()) { + return null; + } + if (keyString != null) { + span.setData(SpanDataConvention.CACHE_KEY_KEY, Arrays.asList(keyString)); + } + return span; + } + + private @Nullable ISpan startSpanForKeys( + final @NotNull String operation, final @NotNull Set keys) { + 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(operation, delegate.getName(), spanOptions); + if (span.isNoOp()) { + return null; + } + span.setData( + SpanDataConvention.CACHE_KEY_KEY, + keys.stream().map(String::valueOf).collect(Collectors.toList())); + 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..375e0f0ca54 --- /dev/null +++ b/sentry-jcache/src/test/kotlin/io/sentry/jcache/SentryJCacheWrapperTest.kt @@ -0,0 +1,491 @@ +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_KEY)) + assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY_KEY)) + assertEquals("auto.cache.jcache", span.spanContext.origin) + } + + @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_KEY)) + } + + // -- 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.get", span.operation) + assertEquals("testCache", span.description) + assertEquals(true, span.getData(SpanDataConvention.CACHE_HIT_KEY)) + val cacheKeys = span.getData(SpanDataConvention.CACHE_KEY_KEY) as List<*> + assertTrue(cacheKeys.containsAll(listOf("k1", "k2"))) + } + + @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_KEY)) + } + + // -- 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(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY_KEY)) + } + + // -- 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.put", tx.spans.first().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.put", span.operation) + assertEquals("testCache", span.description) + val cacheKeys = span.getData(SpanDataConvention.CACHE_KEY_KEY) as List<*> + assertTrue(cacheKeys.containsAll(listOf("k1", "k2"))) + } + + // -- putIfAbsent -- + + @Test + fun `putIfAbsent delegates without creating 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(0, tx.spans.size) + } + + // -- replace (passthrough, no span — conditional write) -- + + @Test + fun `replace with old value delegates without creating 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(0, tx.spans.size) + } + + @Test + fun `replace delegates without creating 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(0, tx.spans.size) + } + + // -- getAndReplace (passthrough, no span — conditional write) -- + + @Test + fun `getAndReplace delegates without creating 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(0, tx.spans.size) + } + + // -- 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) + } + + // -- 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) + } + + // -- 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.remove", tx.spans.first().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.remove", span.operation) + assertEquals("testCache", span.description) + } + + // -- 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.flush", tx.spans.first().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.flush", span.operation) + assertEquals(SpanStatus.OK, span.status) + assertNull(span.getData(SpanDataConvention.CACHE_KEY_KEY)) + } + + // -- 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.get", tx.spans.first().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.get", tx.spans.first().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/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",