Skip to content

Commit da5bde0

Browse files
adinauerclaude
andcommitted
fix(cache): Fix span description, putIfAbsent, and Callable hit detection
- 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>
1 parent 6d6f5eb commit da5bde0

File tree

2 files changed

+47
-30
lines changed

2 files changed

+47
-30
lines changed

sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheWrapper.java

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import io.sentry.SpanStatus;
88
import java.util.Arrays;
99
import java.util.concurrent.Callable;
10+
import java.util.concurrent.atomic.AtomicBoolean;
1011
import org.jetbrains.annotations.ApiStatus;
1112
import org.jetbrains.annotations.NotNull;
1213
import org.jetbrains.annotations.Nullable;
@@ -83,9 +84,15 @@ public SentryCacheWrapper(final @NotNull Cache delegate, final @NotNull IScopes
8384
return delegate.get(key, valueLoader);
8485
}
8586
try {
86-
final T result = delegate.get(key, valueLoader);
87-
// valueLoader is called on miss, so the method always returns a value
88-
span.setData(SpanDataConvention.CACHE_HIT_KEY, true);
87+
final AtomicBoolean loaderInvoked = new AtomicBoolean(false);
88+
final T result =
89+
delegate.get(
90+
key,
91+
() -> {
92+
loaderInvoked.set(true);
93+
return valueLoader.call();
94+
});
95+
span.setData(SpanDataConvention.CACHE_HIT_KEY, !loaderInvoked.get());
8996
span.setStatus(SpanStatus.OK);
9097
return result;
9198
} catch (Throwable e) {
@@ -116,24 +123,14 @@ public void put(final @NotNull Object key, final @Nullable Object value) {
116123
}
117124
}
118125

126+
// putIfAbsent is not instrumented — we cannot know ahead of time whether the put
127+
// will actually happen, and emitting a cache.put span for a no-op would be misleading.
128+
// This matches sentry-python and sentry-javascript which also skip conditional puts.
129+
// We must override to bypass the default implementation which calls this.get() + this.put().
119130
@Override
120131
public @Nullable ValueWrapper putIfAbsent(
121132
final @NotNull Object key, final @Nullable Object value) {
122-
final ISpan span = startSpan("cache.put", key);
123-
if (span == null) {
124-
return delegate.putIfAbsent(key, value);
125-
}
126-
try {
127-
final ValueWrapper result = delegate.putIfAbsent(key, value);
128-
span.setStatus(SpanStatus.OK);
129-
return result;
130-
} catch (Throwable e) {
131-
span.setStatus(SpanStatus.INTERNAL_ERROR);
132-
span.setThrowable(e);
133-
throw e;
134-
} finally {
135-
span.finish();
136-
}
133+
return delegate.putIfAbsent(key, value);
137134
}
138135

139136
@Override
@@ -220,9 +217,10 @@ public boolean invalidate() {
220217

221218
final SpanOptions spanOptions = new SpanOptions();
222219
spanOptions.setOrigin(TRACE_ORIGIN);
223-
final ISpan span = activeSpan.startChild(operation, getName(), spanOptions);
224-
if (key != null) {
225-
span.setData(SpanDataConvention.CACHE_KEY_KEY, Arrays.asList(String.valueOf(key)));
220+
final String keyString = key != null ? String.valueOf(key) : null;
221+
final ISpan span = activeSpan.startChild(operation, keyString, spanOptions);
222+
if (keyString != null) {
223+
span.setData(SpanDataConvention.CACHE_KEY_KEY, Arrays.asList(keyString));
226224
}
227225
return span;
228226
}

sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheWrapperTest.kt

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import kotlin.test.assertEquals
1313
import kotlin.test.assertFailsWith
1414
import kotlin.test.assertNull
1515
import kotlin.test.assertTrue
16+
import org.mockito.kotlin.any
17+
import org.mockito.kotlin.eq
1618
import org.mockito.kotlin.mock
1719
import org.mockito.kotlin.verify
1820
import org.mockito.kotlin.whenever
@@ -52,7 +54,7 @@ class SentryCacheWrapperTest {
5254
assertEquals(1, tx.spans.size)
5355
val span = tx.spans.first()
5456
assertEquals("cache.get", span.operation)
55-
assertEquals("testCache", span.description)
57+
assertEquals("myKey", span.description)
5658
assertEquals(SpanStatus.OK, span.status)
5759
assertEquals(true, span.getData(SpanDataConvention.CACHE_HIT_KEY))
5860
assertEquals(listOf("myKey"), span.getData(SpanDataConvention.CACHE_KEY_KEY))
@@ -103,19 +105,36 @@ class SentryCacheWrapperTest {
103105
// -- get(Object key, Callable<T>) --
104106

105107
@Test
106-
fun `get with callable creates span with cache hit true`() {
108+
fun `get with callable creates span with cache hit true on hit`() {
107109
val tx = createTransaction()
108110
val wrapper = SentryCacheWrapper(delegate, scopes)
109-
val callable = Callable { "loaded" }
110-
whenever(delegate.get("myKey", callable)).thenReturn("loaded")
111+
// Simulate cache hit: delegate returns value without invoking the loader
112+
whenever(delegate.get(eq("myKey"), any<Callable<String>>())).thenReturn("cached")
111113

112-
val result = wrapper.get("myKey", callable)
114+
val result = wrapper.get("myKey", Callable { "loaded" })
113115

114-
assertEquals("loaded", result)
116+
assertEquals("cached", result)
115117
assertEquals(1, tx.spans.size)
116118
assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY))
117119
}
118120

121+
@Test
122+
fun `get with callable creates span with cache hit false on miss`() {
123+
val tx = createTransaction()
124+
val wrapper = SentryCacheWrapper(delegate, scopes)
125+
// Simulate cache miss: delegate invokes the loader callable
126+
whenever(delegate.get(eq("myKey"), any<Callable<String>>())).thenAnswer { invocation ->
127+
val loader = invocation.getArgument<Callable<String>>(1)
128+
loader.call()
129+
}
130+
131+
val result = wrapper.get("myKey", Callable { "loaded" })
132+
133+
assertEquals("loaded", result)
134+
assertEquals(1, tx.spans.size)
135+
assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY))
136+
}
137+
119138
// -- put --
120139

121140
@Test
@@ -136,15 +155,15 @@ class SentryCacheWrapperTest {
136155
// -- putIfAbsent --
137156

138157
@Test
139-
fun `putIfAbsent creates cache put span`() {
158+
fun `putIfAbsent delegates without creating span`() {
140159
val tx = createTransaction()
141160
val wrapper = SentryCacheWrapper(delegate, scopes)
142161
whenever(delegate.putIfAbsent("myKey", "myValue")).thenReturn(null)
143162

144163
wrapper.putIfAbsent("myKey", "myValue")
145164

146-
assertEquals(1, tx.spans.size)
147-
assertEquals("cache.put", tx.spans.first().operation)
165+
verify(delegate).putIfAbsent("myKey", "myValue")
166+
assertEquals(0, tx.spans.size)
148167
}
149168

150169
// -- evict --

0 commit comments

Comments
 (0)