Skip to content

feat(spring): [Cache Tracing 1] Add SentryCacheWrapper and SentryCacheManagerWrapper#5172

Open
adinauer wants to merge 6 commits intofeat/cache-tracingfrom
feat/cache-tracing-wrappers
Open

feat(spring): [Cache Tracing 1] Add SentryCacheWrapper and SentryCacheManagerWrapper#5172
adinauer wants to merge 6 commits intofeat/cache-tracingfrom
feat/cache-tracing-wrappers

Conversation

@adinauer
Copy link
Member

@adinauer adinauer commented Mar 9, 2026

PR Stack (Cache Tracing)

  • #5172 — Add SentryCacheWrapper and SentryCacheManagerWrapper
  • #5173 — Add enableCacheTracing option
  • #5174 — Add BeanPostProcessor and auto-configuration
  • #5175 — Add cache tracing e2e sample
  • #5179 — Add SentryJCacheWrapper for JCache (JSR-107)
  • #5182 — Add JCache console sample
  • #5183 — Add cache tracing to all Spring Boot 4 samples
  • #5184 — Add retrieve() overrides for reactive/async cache support

📜 Description

Adds SentryCacheWrapper and SentryCacheManagerWrapper to the sentry-spring-7 module. These wrapper classes instrument Spring's Cache and CacheManager interfaces to produce cache.get, cache.put, cache.remove, and cache.flush spans per the Sentry cache module spec.

Also adds CACHE_HIT_KEY and CACHE_KEY_KEY constants to SpanDataConvention.

💚 How did you test it?

  • Unit tests for SentryCacheWrapper covering all cache operations, hit/miss detection, error handling, and span data assertions
  • Unit tests for SentryCacheManagerWrapper covering cache wrapping, null passthrough, and getCacheNames delegation

📝 Checklist

  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • No breaking change or entry added to the changelog.

adinauer and others added 4 commits March 2, 2026 16:45
…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>
@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

  • (anr) Profile main thread when ANR and report ANR profiles to Sentry by markushi in #4899
  • (spring) [Cache Tracing 1] Add SentryCacheWrapper and SentryCacheManagerWrapper by adinauer in #5172

Bug Fixes 🐛

  • Remove the dependency on protobuf-lite for tombstones by supervacuus in #5157

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

Fails
🚫 Please consider adding a changelog entry for the next release.
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Instructions and example for changelog

Please add an entry to CHANGELOG.md to the "Unreleased" section. Make sure the entry includes this PR's number.

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 #skip-changelog to the PR description or adding a skip-changelog label.

Generated by 🚫 dangerJS against 0b1b83a

@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 324.39 ms 342.27 ms 17.88 ms
Size 0 B 0 B 0 B

Previous results on branch: feat/cache-tracing-wrappers

Startup times

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
Copy link

sentry bot commented Mar 9, 2026

Sentry Build Distribution

App Version Configuration
SDK Size 8.34.1 (1) release

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

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 SentryCacheWrapper guard in SentryCacheManagerWrapper.getCache() to return already wrapped caches without wrapping again.
  • ✅ Fixed: Cache hit misreported for stored null values
    • Updated SentryCacheWrapper.get(key, type) to mark cache.hit as true when the typed result is null but delegate.get(key) confirms a stored entry.

Create PR

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
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Copy link
Collaborator

@lbloder lbloder left a comment

Choose a reason for hiding this comment

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

LGTM 👍

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);
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.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

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;
}
}
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

@sentry
Copy link

sentry bot commented Mar 10, 2026

Sentry Build Distribution

App Version Configuration
SDK Size 8.34.1 (1) release

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants