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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions sentry-spring-7/api/sentry-spring-7.api
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@ public final class io/sentry/spring7/SpringSecuritySentryUserProvider : io/sentr
public fun provideUser ()Lio/sentry/protocol/User;
}

public final class io/sentry/spring7/cache/SentryCacheManagerWrapper : org/springframework/cache/CacheManager {
public fun <init> (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 <init> (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/spring7/checkin/SentryCheckIn : java/lang/annotation/Annotation {
public abstract fun heartbeat ()Z
public abstract fun monitorSlug ()Ljava/lang/String;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> getCacheNames() {
return delegate.getCacheNames();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
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.Arrays;
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("cache.get", key);
if (span == null) {
return delegate.get(key);
}
try {
final ValueWrapper result = delegate.get(key);
span.setData(SpanDataConvention.CACHE_HIT_KEY, result != null);
span.setStatus(SpanStatus.OK);
return result;
} catch (Throwable e) {
span.setStatus(SpanStatus.INTERNAL_ERROR);
span.setThrowable(e);
throw e;
} finally {
span.finish();
}
}

@Override
public @Nullable <T> T get(final @NotNull Object key, final @Nullable Class<T> type) {
final ISpan span = startSpan("cache.get", key);
if (span == null) {
return delegate.get(key, type);
}
try {
final T result = delegate.get(key, type);
span.setData(SpanDataConvention.CACHE_HIT_KEY, result != null);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The get(Object key, Class<T> type) method incorrectly uses result != null to determine a cache hit, failing to distinguish a cache miss from a cached null value.
Severity: MEDIUM

Suggested Fix

The get(Object key, Class<T> type) method cannot reliably determine a cache hit from its return value alone. The instrumentation for this specific method should be removed, as its ambiguity makes accurate reporting impossible without deeper integration with the underlying cache implementation. This follows the pattern already used for putIfAbsent in the same class.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheWrapper.java#L68

Potential issue: In `SentryCacheWrapper`, the `get(Object key, Class<T> type)` method
determines cache hit status by checking if the result is non-null. This logic is flawed
because Spring's `Cache` interface supports storing `null` values. When a cached value
is `null`, this method will return `null`, causing the implementation to incorrectly
report a cache miss (`cache.hit = false`) instead of a cache hit (`cache.hit = true`).
This leads to inaccurate cache hit metrics for applications that legitimately cache
`null` values. The `get(Object key)` variant correctly handles this by checking the
`ValueWrapper`, but this typed overload does not.

Did we get this right? 👍 / 👎 to inform future reviews.

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> T get(final @NotNull Object key, final @NotNull Callable<T> valueLoader) {
final ISpan span = startSpan("cache.get", key);
if (span == null) {
return delegate.get(key, valueLoader);
}
try {
final AtomicBoolean loaderInvoked = new AtomicBoolean(false);
final T result =
delegate.get(
key,
() -> {
loaderInvoked.set(true);
return valueLoader.call();
});
span.setData(SpanDataConvention.CACHE_HIT_KEY, !loaderInvoked.get());
span.setStatus(SpanStatus.OK);
return result;
} catch (Throwable e) {
span.setStatus(SpanStatus.INTERNAL_ERROR);
span.setThrowable(e);
throw e;
} finally {
span.finish();
}
}

@Override
public void put(final @NotNull Object key, final @Nullable Object value) {
final ISpan span = startSpan("cache.put", key);
if (span == null) {
delegate.put(key, value);
return;
}
try {
delegate.put(key, value);
span.setStatus(SpanStatus.OK);
} catch (Throwable e) {
span.setStatus(SpanStatus.INTERNAL_ERROR);
span.setThrowable(e);
throw e;
} finally {
span.finish();
}
}

// putIfAbsent is not instrumented — we cannot know ahead of time whether the put
// will actually happen, and emitting a cache.put span for a no-op would be misleading.
// This matches sentry-python and sentry-javascript which also skip conditional puts.
// We must override to bypass the default implementation which calls this.get() + this.put().
@Override
public @Nullable ValueWrapper putIfAbsent(
final @NotNull Object key, final @Nullable Object value) {
return delegate.putIfAbsent(key, value);
}

@Override
public void evict(final @NotNull Object key) {
final ISpan span = startSpan("cache.remove", key);
if (span == null) {
delegate.evict(key);
return;
}
try {
delegate.evict(key);
span.setStatus(SpanStatus.OK);
} catch (Throwable e) {
span.setStatus(SpanStatus.INTERNAL_ERROR);
span.setThrowable(e);
throw e;
} finally {
span.finish();
}
}

@Override
public boolean evictIfPresent(final @NotNull Object key) {
final ISpan span = startSpan("cache.remove", key);
if (span == null) {
return delegate.evictIfPresent(key);
}
try {
final boolean result = delegate.evictIfPresent(key);
span.setStatus(SpanStatus.OK);
return result;
} catch (Throwable e) {
span.setStatus(SpanStatus.INTERNAL_ERROR);
span.setThrowable(e);
throw e;
} finally {
span.finish();
}
}

@Override
public void clear() {
final ISpan span = startSpan("cache.flush", null);
if (span == null) {
delegate.clear();
return;
}
try {
delegate.clear();
span.setStatus(SpanStatus.OK);
} catch (Throwable e) {
span.setStatus(SpanStatus.INTERNAL_ERROR);
span.setThrowable(e);
throw e;
} finally {
span.finish();
}
}

@Override
public boolean invalidate() {
final ISpan span = startSpan("cache.flush", null);
if (span == null) {
return delegate.invalidate();
}
try {
final boolean result = delegate.invalidate();
span.setStatus(SpanStatus.OK);
return result;
} catch (Throwable e) {
span.setStatus(SpanStatus.INTERNAL_ERROR);
span.setThrowable(e);
throw e;
} finally {
span.finish();
}
}

private @Nullable ISpan startSpan(final @NotNull String operation, final @Nullable Object key) {
final ISpan activeSpan = scopes.getSpan();
if (activeSpan == null || activeSpan.isNoOp()) {
return null;
}

final SpanOptions spanOptions = new SpanOptions();
spanOptions.setOrigin(TRACE_ORIGIN);
final String keyString = key != null ? String.valueOf(key) : null;
final ISpan span = activeSpan.startChild(operation, keyString, spanOptions);
if (keyString != null) {
span.setData(SpanDataConvention.CACHE_KEY_KEY, Arrays.asList(keyString));
}
return span;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing retrieve overrides bypass delegate's async implementation

Medium Severity

SentryCacheWrapper doesn't override the retrieve(Object key) and retrieve(Object key, Supplier) default methods from Spring's Cache interface. The default retrieve(key) calls this.get(key) and retrieve(key, supplier) calls this.retrieve(key) then potentially this.put(key, value). This is the same class of problem the putIfAbsent override addresses (comment: "We must override to bypass the default implementation which calls this.get() + this.put()"). For caches with custom async retrieve implementations (e.g., CaffeineCache with AsyncCache), the delegate's async behavior is silently replaced with synchronous get(), potentially causing thread blocking in reactive/async @Cacheable methods.

Additional Locations (1)

Fix in Cursor Fix in Web

Original file line number Diff line number Diff line change
@@ -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<Cache>()
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<Cache>()
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)
}
}
Loading
Loading