Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Fixes

- Keep dropped tombstone and ANR events dropped, instead of reporting the same app exit again at every app start ([#6002](https://github.com/getsentry/sentry-java/pull/6002))
- Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003))
- Apply `Sentry.withScope` and `Sentry.withIsolationScope` data to events captured inside the callback when `globalHubMode` is enabled ([#6004](https://github.com/getsentry/sentry-java/pull/6004))
- `globalHubMode` is enabled by default on Android, where tags, extras, contexts and level set inside the callback were silently dropped
- Scopes that are explicitly made current, e.g. via `Sentry.setCurrentScopes` or the `SentryContext` coroutine integration, are now also honoured when `globalHubMode` is enabled
Expand Down
1 change: 1 addition & 0 deletions sentry-okhttp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies {
testImplementation(libs.mockito.inline)
testImplementation(libs.okhttp)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.google.truth)
}

buildConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ public open class SentryOkHttpEventListener(
private val scopes: IScopes = ScopesAdapter.getInstance(),
private val originalEventListenerCreator: ((call: Call) -> EventListener)? = null,
) : EventListener() {
private var originalEventListener: EventListener? = null
private val originalEventListenerMap: ConcurrentHashMap<Call, EventListener> = ConcurrentHashMap()

// Set only by the constructors that wrap a single EventListener instance. Such a listener is
// shared by every Call anyway, exactly like OkHttp's own EventListener.asFactory(), so it
// exists independently of the callStart()..callEnd() window and can always be delegated to.
private var fixedOriginalEventListener: EventListener? = null

public companion object {
internal const val PROXY_SELECT_EVENT = "http.client.proxy_select_ms"
Expand All @@ -65,7 +70,9 @@ public open class SentryOkHttpEventListener(

public constructor(
originalEventListener: EventListener
) : this(ScopesAdapter.getInstance(), originalEventListenerCreator = { originalEventListener })
) : this(ScopesAdapter.getInstance(), originalEventListenerCreator = { originalEventListener }) {
fixedOriginalEventListener = originalEventListener
}

public constructor(
originalEventListenerFactory: Factory
Expand All @@ -77,35 +84,41 @@ public open class SentryOkHttpEventListener(
public constructor(
scopes: IScopes = ScopesAdapter.getInstance(),
originalEventListener: EventListener,
) : this(scopes, originalEventListenerCreator = { originalEventListener })
) : this(scopes, originalEventListenerCreator = { originalEventListener }) {
fixedOriginalEventListener = originalEventListener
}

public constructor(
scopes: IScopes = ScopesAdapter.getInstance(),
originalEventListenerFactory: Factory,
) : this(scopes, originalEventListenerCreator = { originalEventListenerFactory.create(it) })

override fun callStart(call: Call) {
originalEventListener = originalEventListenerCreator?.invoke(call)
// The EventListener.Factory contract binds a listener to a single call, so the wrapped
// listener is kept per call instead of in a field shared by all concurrent calls
val originalEventListener = getOrCreateEventListener(call)
originalEventListener?.callStart(call)
// If the wrapped EventListener is ours, we can just delegate the calls,
// without creating other events that would create duplicates
if (canCreateEventSpan()) {
if (canCreateEventSpan(originalEventListener)) {
eventMap[call] = SentryOkHttpEvent(scopes, call.request())
}
}

override fun proxySelectStart(call: Call, url: HttpUrl) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.proxySelectStart(call, url)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventStart(PROXY_SELECT_EVENT)
}

override fun proxySelectEnd(call: Call, url: HttpUrl, proxies: List<Proxy>) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.proxySelectEnd(call, url, proxies)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
Expand All @@ -117,17 +130,19 @@ public open class SentryOkHttpEventListener(
}

override fun dnsStart(call: Call, domainName: String) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.dnsStart(call, domainName)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventStart(DNS_EVENT)
}

override fun dnsEnd(call: Call, domainName: String, inetAddressList: List<InetAddress>) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.dnsEnd(call, domainName, inetAddressList)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
Expand All @@ -140,26 +155,29 @@ public open class SentryOkHttpEventListener(
}

override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.connectStart(call, inetSocketAddress, proxy)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventStart(CONNECT_EVENT)
}

override fun secureConnectStart(call: Call) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.secureConnectStart(call)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventStart(SECURE_CONNECT_EVENT)
}

override fun secureConnectEnd(call: Call, handshake: Handshake?) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.secureConnectEnd(call, handshake)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
Expand All @@ -172,8 +190,9 @@ public open class SentryOkHttpEventListener(
proxy: Proxy,
protocol: Protocol?,
) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.connectEnd(call, inetSocketAddress, proxy, protocol)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
Expand All @@ -188,8 +207,9 @@ public open class SentryOkHttpEventListener(
protocol: Protocol?,
ioe: IOException,
) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.connectFailed(call, inetSocketAddress, proxy, protocol, ioe)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
Expand All @@ -202,53 +222,59 @@ public open class SentryOkHttpEventListener(
}

override fun connectionAcquired(call: Call, connection: Connection) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.connectionAcquired(call, connection)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventStart(CONNECTION_EVENT)
}

override fun connectionReleased(call: Call, connection: Connection) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.connectionReleased(call, connection)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventFinish(CONNECTION_EVENT)
}

override fun requestHeadersStart(call: Call) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.requestHeadersStart(call)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventStart(REQUEST_HEADERS_EVENT)
}

override fun requestHeadersEnd(call: Call, request: Request) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.requestHeadersEnd(call, request)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventFinish(REQUEST_HEADERS_EVENT)
}

override fun requestBodyStart(call: Call) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.requestBodyStart(call)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventStart(REQUEST_BODY_EVENT)
}

override fun requestBodyEnd(call: Call, byteCount: Long) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.requestBodyEnd(call, byteCount)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
Expand All @@ -261,8 +287,9 @@ public open class SentryOkHttpEventListener(
}

override fun requestFailed(call: Call, ioe: IOException) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.requestFailed(call, ioe)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
Expand All @@ -282,17 +309,19 @@ public open class SentryOkHttpEventListener(
}

override fun responseHeadersStart(call: Call) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.responseHeadersStart(call)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventStart(RESPONSE_HEADERS_EVENT)
}

override fun responseHeadersEnd(call: Call, response: Response) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.responseHeadersEnd(call, response)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
Expand All @@ -307,17 +336,19 @@ public open class SentryOkHttpEventListener(
}

override fun responseBodyStart(call: Call) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.responseBodyStart(call)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
okHttpEvent.onEventStart(RESPONSE_BODY_EVENT)
}

override fun responseBodyEnd(call: Call, byteCount: Long) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.responseBodyEnd(call, byteCount)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
Expand All @@ -330,8 +361,9 @@ public open class SentryOkHttpEventListener(
}

override fun responseFailed(call: Call, ioe: IOException) {
val originalEventListener = originalEventListenerMap[call]
originalEventListener?.responseFailed(call, ioe)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return
Comment thread
markushi marked this conversation as resolved.
Expand All @@ -351,14 +383,15 @@ public open class SentryOkHttpEventListener(
}

override fun callEnd(call: Call) {
originalEventListener?.callEnd(call)
originalEventListenerMap.remove(call)?.callEnd(call)
val okHttpEvent: SentryOkHttpEvent = eventMap.remove(call) ?: return
okHttpEvent.finish()
}

override fun callFailed(call: Call, ioe: IOException) {
val originalEventListener = originalEventListenerMap.remove(call)
originalEventListener?.callFailed(call, ioe)
if (!canCreateEventSpan()) {
if (!canCreateEventSpan(originalEventListener)) {
return
}
val okHttpEvent: SentryOkHttpEvent = eventMap.remove(call) ?: return
Expand All @@ -370,26 +403,43 @@ public open class SentryOkHttpEventListener(
}

override fun canceled(call: Call) {
// canceled() is not part of the call window: OkHttp may deliver it before callStart() and
// after callEnd()/callFailed(), because it holds the listener for the whole Call lifetime
// while we only keep it for the duration of the call.
val originalEventListener =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We're close! (and thanks for the great updates)

We still need to preserve the contract of EventListener.Factory that ensures only one EventListener instance is produced per Call lifecycle. Ie, the listener returned by the factory for a given call needs to be the listener that captures i) all of that call's lifecycle and ii) no other call's lifecycle.

We've fixed (ii), but we're still violating (i) in the case of cancelation because we're creating an extra listener for early and late cancel() invocations.

Possible solution

Thoughts about using a weak per-call map for the wrapped listener instead? Something like a WeakHashMap<Call, EventListener> guarded by synchronized, with a getOrCreateOriginalEventListener(call) helper used by both callStart and canceled().

That'd^^ let us avoid removing entries on callEnd / callFailed, and completed calls would be gc'd as soon as the Call instance is unreachable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 728129d. You were right that creating an extra listener was the wrong trade — and a WeakHashMap turned out not to be usable here, for a reason our own code demonstrates.

Why not weak keys. WeakHashMap holds values by ordinary strong references, and its javadoc warns that a value which strongly refers to its own key prevents the key from being discarded. That is exactly our shape: a factory is handed the Call, so listeners that keep it are the normal case (our own RecordingListener(val ownCall: Call) does it). Worse, the sibling eventMap already has this cycle inside the SDK: SentryOkHttpEvent holds a Response, Response.exchange is an Exchange, and Exchange.call is the RealCall. So a weak map would stop collecting as soon as a response is recorded. It is also unsynchronized, and getTable() calls expungeStaleEntries(), so even reads would need the monitor on a path OkHttp explicitly allows to run concurrently.

What we do instead. OkHttp stores the listener on the call itself (RealCall:73, client.eventListenerFactory.create(this)), so its lifetime is the Call object's lifetime. Ours is the callStart()..callEnd() window. canceled() is the one event that escapes that window, so it is the one that needed handling — and Call.isExecuted() tells the two edges apart without any storage of our own:

  • not executed — the cancel precedes callStart(). computeIfAbsent creates and binds the listener, and callStart() then reuses it, so one listener sees the whole lifecycle. It is self-cleaning: getResponseWithInterceptorChain does if (canceled) throw IOException("Canceled"), so a pre-canceled call that is later executed still runs callStartcallFailed and the entry is removed.
  • executed, entry present — in flight, delegated to the bound listener.
  • executed, entry absent — the terminal event already passed. Ignored. Call.cancel() is documented as "Requests that are already complete cannot be canceled", so there is nothing to report, and fabricating a second listener would both break the contract and leak the entry.

A stored "terminal" flag would have worked too, but it needs a per-Call marker that no later event can ever remove — the same unbounded growth, just with a smaller value behind a Call key. isExecuted() is that flag, maintained by OkHttp, for free.

Two supporting changes: getOrCreateEventListener uses ConcurrentHashMap.computeIfAbsent rather than a get-then-put, so a cancel racing callStart() cannot make the factory produce two listeners for one call; and the constructors that wrap a single EventListener now keep it in a field. That instance is shared across calls by definition — it is precisely what OkHttp's own EventListener.asFactory() does — so it exists independently of the window and receives every cancel, which restores the pre-PR behaviour you flagged with no leak and no contract question.

Residual gap, stated plainly: a call that is canceled before it starts and then never executed keeps its map entry, because no terminal event ever arrives. That is far narrower than the late-cancel leak it replaces, and bounding it would need the weak keys that do not work here.

originalEventListenerMap[call]
?: fixedOriginalEventListener
// The call already reached its terminal event, so its listener is gone. Call.cancel() is

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The canceled() method incorrectly uses call.isExecuted() to detect post-terminal calls, causing cancel events to be dropped if they occur after enqueue() but before callStart().
Severity: MEDIUM

Suggested Fix

The logic in the canceled() method should be adjusted to not rely solely on call.isExecuted() to determine if a call is post-terminal. The check if (call.isExecuted()) should be removed or modified to correctly handle the state where a call has been enqueued but callStart has not yet been invoked, ensuring the listener is created and the canceled event is properly delegated.

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-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEventListener.kt#L412

Potential issue: In `SentryOkHttpEventListener`, when using a factory-based listener,
there is a race condition in the `canceled()` method. The logic checks
`call.isExecuted()` to determine if a call is in a terminal state. However, OkHttp's
`isExecuted()` returns `true` as soon as a call is enqueued, which can occur before the
`callStart()` event is fired. If a call is canceled during this window (after
`enqueue()` but before `callStart()`), the `canceled()` method will incorrectly assume
the call is complete and will not delegate the event. This results in the `cancel` event
being lost, potentially leading to inaccurate transaction data where the associated span
is not marked as canceled.

// documented as a no-op for a completed request, thus there is nothing to report, and
// creating a second listener here would break the Factory contract and leak the entry.
?: if (call.isExecuted()) null else getOrCreateEventListener(call)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancel before start leaks listeners

Medium Severity

canceled() now inserts a factory-created listener into originalEventListenerMap when isExecuted() is false. That entry is only removed in callEnd or callFailed, so a Call that is canceled and never started stays in the map for the life of the SentryOkHttpEventListener.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 728129d. Configure here.

originalEventListener?.canceled(call)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Start race drops canceled callback

Medium Severity

Call.isExecuted() becomes true at the start of enqueue/execute, before callStart inserts the listener. A concurrent canceled() can then see an empty map and a true isExecuted() flag and drop the event, so the listener created moments later never receives canceled().

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 728129d. Configure here.

}

override fun satisfactionFailure(call: Call, response: Response) {
originalEventListener?.satisfactionFailure(call, response)
originalEventListenerMap[call]?.satisfactionFailure(call, response)
}

override fun cacheHit(call: Call, response: Response) {
originalEventListener?.cacheHit(call, response)
originalEventListenerMap[call]?.cacheHit(call, response)
}

override fun cacheMiss(call: Call) {
originalEventListener?.cacheMiss(call)
originalEventListenerMap[call]?.cacheMiss(call)
}

override fun cacheConditionalHit(call: Call, cachedResponse: Response) {
originalEventListener?.cacheConditionalHit(call, cachedResponse)
originalEventListenerMap[call]?.cacheConditionalHit(call, cachedResponse)
}

// computeIfAbsent, so that a cancel racing callStart() cannot make the Factory produce two
// listeners for the same Call
private fun getOrCreateEventListener(call: Call): EventListener? {
val creator = originalEventListenerCreator ?: return null
return originalEventListenerMap.computeIfAbsent(call) { creator.invoke(it) }
}

private fun canCreateEventSpan(): Boolean {
private fun canCreateEventSpan(originalEventListener: EventListener?): Boolean {
// If the wrapped EventListener is ours, we shouldn't create spans, as the originalEventListener
// already did it
// In case SentryOkHttpEventListener from sentry-android-okhttp is used, the is check won't work
Expand Down
Loading
Loading