feat(spring): [Cache Tracing 1] Add SentryCacheWrapper and SentryCacheManagerWrapper#5172
feat(spring): [Cache Tracing 1] Add SentryCacheWrapper and SentryCacheManagerWrapper#5172adinauer wants to merge 6 commits intofeat/cache-tracingfrom
Conversation
…eManagerWrapper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tion - Use cache key as span description instead of cache name, matching the spec and other SDKs (Python, JavaScript) - Skip instrumentation for putIfAbsent since we cannot know if a write actually occurred; override to bypass default get()+put() delegation - Wrap valueLoader Callable in get(key, Callable) to detect cache hit/miss instead of always reporting hit=true - Update tests to match new behavior Co-Authored-By: Claude <noreply@anthropic.com>
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. New Features ✨
Bug Fixes 🐛
🤖 This preview updates automatically when you update the PR. |
Instructions and example for changelogPlease add an entry to Example: ## Unreleased
### Features
- [Cache Tracing 1] Add SentryCacheWrapper and SentryCacheManagerWrapper ([#5172](https://github.com/getsentry/sentry-java/pull/5172))If none of the above apply, you can opt out of this check by adding |
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 3e5f591 | 317.48 ms | 356.80 ms | 39.32 ms |
| 2876c40 | 339.24 ms | 405.65 ms | 66.41 ms |
| 8baac33 | 313.15 ms | 367.84 ms | 54.69 ms |
| 4b44539 | 255.79 ms | 300.12 ms | 44.33 ms |
| f94f681 | 405.20 ms | 453.20 ms | 48.00 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 3e5f591 | 0 B | 0 B | 0 B |
| 2876c40 | 1.58 MiB | 2.29 MiB | 723.29 KiB |
| 8baac33 | 0 B | 0 B | 0 B |
| 4b44539 | 0 B | 0 B | 0 B |
| f94f681 | 0 B | 0 B | 0 B |
Sentry Build Distribution
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Missing double-wrapping guard creates duplicate spans
- Added an
instanceof SentryCacheWrapperguard inSentryCacheManagerWrapper.getCache()to return already wrapped caches without wrapping again.
- Added an
- ✅ Fixed: Cache hit misreported for stored null values
- Updated
SentryCacheWrapper.get(key, type)to markcache.hitas true when the typed result is null butdelegate.get(key)confirms a stored entry.
- Updated
Or push these changes by commenting:
@cursor push c7c4fa1f63
Preview (c7c4fa1f63)
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
--- 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
@@ -27,6 +27,9 @@
if (cache == null) {
return null;
}
+ if (cache instanceof SentryCacheWrapper) {
+ return cache;
+ }
return new SentryCacheWrapper(cache, scopes);
}
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
--- 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
@@ -65,7 +65,8 @@
}
try {
final T result = delegate.get(key, type);
- span.setData(SpanDataConvention.CACHE_HIT_KEY, result != null);
+ final boolean cacheHit = result != null || delegate.get(key) != null;
+ span.setData(SpanDataConvention.CACHE_HIT_KEY, cacheHit);
span.setStatus(SpanStatus.OK);
return result;
} catch (Throwable e) {
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
--- 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
@@ -4,6 +4,7 @@
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
@@ -37,6 +38,18 @@
}
@Test
+ fun `getCache does not re-wrap SentryCacheWrapper`() {
+ val cache = mock<Cache>()
+ val wrappedCache = SentryCacheWrapper(cache, scopes)
+ whenever(delegate.getCache("test")).thenReturn(wrappedCache)
+
+ val wrapper = SentryCacheManagerWrapper(delegate, scopes)
+ val result = wrapper.getCache("test")
+
+ assertSame(wrappedCache, result)
+ }
+
+ @Test
fun `getCacheNames delegates to underlying cache manager`() {
whenever(delegate.cacheNames).thenReturn(listOf("cache1", "cache2"))
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
--- 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
@@ -102,6 +102,21 @@
assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY))
}
+ @Test
+ fun `get with type creates span with cache hit true for stored null value`() {
+ val tx = createTransaction()
+ val wrapper = SentryCacheWrapper(delegate, scopes)
+ val valueWrapper = mock<Cache.ValueWrapper>()
+ whenever(delegate.get("myKey", String::class.java)).thenReturn(null)
+ whenever(delegate.get("myKey")).thenReturn(valueWrapper)
+
+ val result = wrapper.get("myKey", String::class.java)
+
+ assertNull(result)
+ assertEquals(1, tx.spans.size)
+ assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY))
+ }
+
// -- get(Object key, Callable<T>) --
@Test
sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheManagerWrapper.java
Show resolved
Hide resolved
sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheWrapper.java
Show resolved
Hide resolved
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| } | ||
| try { | ||
| final T result = delegate.get(key, type); | ||
| span.setData(SpanDataConvention.CACHE_HIT_KEY, result != null); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| } | ||
| return span; | ||
| } | ||
| } |
There was a problem hiding this comment.
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)
Sentry Build Distribution
|



PR Stack (Cache Tracing)
📜 Description
Adds
SentryCacheWrapperandSentryCacheManagerWrapperto thesentry-spring-7module. These wrapper classes instrument Spring'sCacheandCacheManagerinterfaces to producecache.get,cache.put,cache.remove, andcache.flushspans per the Sentry cache module spec.Also adds
CACHE_HIT_KEYandCACHE_KEY_KEYconstants toSpanDataConvention.💚 How did you test it?
SentryCacheWrappercovering all cache operations, hit/miss detection, error handling, and span data assertionsSentryCacheManagerWrappercovering cache wrapping, null passthrough, andgetCacheNamesdelegation📝 Checklist
sendDefaultPIIis enabled.