Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
62cff9b
fix(okhttp): Don't block on response bodies of unknown length
carlonzo Oct 2, 2026
4d7c686
changelog: reference the pull request
carlonzo Oct 2, 2026
554d121
fix(okhttp): Satisfy detekt for the streaming body capture
carlonzo Oct 2, 2026
0d5e6df
refactor(okhttp): Drop unnecessary locking from the capturing source
carlonzo Oct 2, 2026
a982ceb
fix: Publish late response details safely
carlonzo Oct 2, 2026
1c6174f
test: Cover late-filled network request data
carlonzo Oct 2, 2026
9377594
fix(okhttp): Harden the capture of response bodies with no known length
markushi Oct 6, 2026
a93c694
test(okhttp): Cover async, cancelled and HTTP/2 bodies of unknown length
markushi Oct 6, 2026
b77f96a
ref: Publish both sides of NetworkRequestData the same way
markushi Oct 6, 2026
17d5134
fix(okhttp): Keep the bytes received before a stream breaks
markushi Oct 6, 2026
f9e79c0
ref: Read the response details of a network breadcrumb as one snapshot
markushi Oct 6, 2026
5b2bf58
ref(okhttp): Capture every response body through one path
markushi Oct 7, 2026
8879de2
changelog
markushi Oct 7, 2026
09687ef
test: Silence R8 for the okhttp types the new response body extends
markushi Oct 7, 2026
ead659c
ref(okhttp): Rename NetworkBodyCapturingResponseBody to CapturedRespoโ€ฆ
markushi Oct 8, 2026
8193c36
ref(okhttp): Capture only the response bytes the application reads
markushi Oct 8, 2026
406bd4e
Merge branch 'main' into fix/okhttp-unknown-length-response-body [pr-โ€ฆ
sentry-junior[bot] Oct 21, 2026
d490996
Merge branch 'main' into fix/okhttp-unknown-length-response-body [pr-โ€ฆ
sentry-junior[bot] Oct 22, 2026
6a5a086
test(okhttp): Wait for the capture, not for the source handing bytes โ€ฆ
markushi Oct 22, 2026
b406d8b
ref: Keep the status code inside the response details
markushi Oct 22, 2026
ab94b11
ref(okhttp): Restore the original response flow and rethrow fatal capโ€ฆ
markushi Oct 22, 2026
7c50f57
ref(okhttp): Drop comments that restate the code
markushi Oct 22, 2026
fa85e61
ref: Drop the constructor comment on ReplayNetworkRequestOrResponse
markushi Oct 22, 2026
4dbcbfa
Merge branch 'main' into fix/okhttp-unknown-length-response-body [pr-โ€ฆ
sentry-junior[bot] Oct 22, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixes

- Fix `SentryOkHttpInterceptor` hanging forever on responses whose body has no known length, such as Server-Sent Events or a gzipped response ([#6231](https://github.com/getsentry/sentry-java/pull/6231))

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.

l: Might want to add a "Session Replay:" prefix to this so we don't suggest this was happening to everyone (could also add a qualification that it only impacted folks who had network details turned on if you think that'd be helpful).

- Always remove the first-draw listener after the first frame, even if no layout pass follows it ([#6152](https://github.com/getsentry/sentry-java/pull/6152))

## 8.61.1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,7 @@
-dontwarn kotlin.math.MathKt
-dontwarn okhttp3.EventListener
-dontwarn okhttp3.Interceptor
-dontwarn okhttp3.ResponseBody
-dontwarn okio.ForwardingSource
# Assume all classes are used to not strip them out, e.g. integrations like Compose or Sqlite
-keep class io.sentry.**
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,12 @@ public open class DefaultReplayBreadcrumbConverter() : ReplayBreadcrumbConverter

// Add Network Details data when available
networkDetailData?.let { networkData ->
val responseDetails = networkData.response

networkData.method?.let { breadcrumbData["method"] = it }
networkData.statusCode?.let { breadcrumbData["statusCode"] = it }
responseDetails?.statusCode?.let { breadcrumbData["statusCode"] = it }
networkData.requestBodySize?.let { breadcrumbData["requestBodySize"] = it }
networkData.responseBodySize?.let { breadcrumbData["responseBodySize"] = it }
responseDetails?.size?.let { breadcrumbData["responseBodySize"] = it }

networkData.request?.let { request ->
val requestData = mutableMapOf<String, Any?>()
Expand All @@ -257,7 +259,7 @@ public open class DefaultReplayBreadcrumbConverter() : ReplayBreadcrumbConverter
}
}

networkData.response?.let { response ->
responseDetails?.let { response ->
val responseData = mutableMapOf<String, Any?>()
response.size?.let { responseData["size"] = it }
response.body?.let {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,12 +429,12 @@ class DefaultReplayBreadcrumbConverterTest {
)
)
fakeOkHttpNetworkDetails.setResponseDetails(
200,
ReplayNetworkRequestOrResponse(
200,
500L,
NetworkBody(mapOf("status" to "success", "message" to "OK")),
mapOf("Content-Type" to "text/plain"),
),
)
)
val hintWithFakeOKHttpNetworkDetails = Hint()
hintWithFakeOKHttpNetworkDetails.set(SENTRY_REPLAY_NETWORK_DETAILS, fakeOkHttpNetworkDetails)
Expand Down Expand Up @@ -491,12 +491,12 @@ class DefaultReplayBreadcrumbConverterTest {
)
)
fakeOkHttpNetworkDetails.setResponseDetails(
404,
ReplayNetworkRequestOrResponse(
404,
550L,
NetworkBody(mapOf("status" to "success", "message" to "OK")),
mapOf("Content-Type" to "text/plain"),
),
)
)
val hintWithFakeOKHttpNetworkDetails = Hint()
hintWithFakeOKHttpNetworkDetails.set(SENTRY_REPLAY_NETWORK_DETAILS, fakeOkHttpNetworkDetails)
Expand Down Expand Up @@ -546,12 +546,12 @@ class DefaultReplayBreadcrumbConverterTest {
)
)
networkRequestData.setResponseDetails(
200,
ReplayNetworkRequestOrResponse(
200,
100L,
NetworkBody("response body content"),
mapOf("Content-Type" to "application/json"),
),
)
)
hint.set(SENTRY_REPLAY_NETWORK_DETAILS, networkRequestData)

Expand Down
100 changes: 100 additions & 0 deletions sentry-okhttp/src/main/java/io/sentry/okhttp/CapturedResponseBody.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package io.sentry.okhttp

import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import okhttp3.MediaType
import okhttp3.ResponseBody
import okio.Buffer
import okio.BufferedSource
import okio.ForwardingSource
import okio.Source
import okio.buffer

/**
* A [ResponseBody] that copies the bytes the application reads into a capped buffer.
*
* @param delegate the body to capture from.
* @param maxBytes the maximum number of bytes to retain; capture stops once it is reached.
* @param onCaptured invoked at most once, synchronously, on the thread that finishes the body โ€” so
* it must not block. A body that is abandoned without being read to the end or closed never

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.

l: Double-checking that the onCaptured KDoc is correct? I see, eg, that we invoke the callback from CapturedResponseBody.read() if we hit maxBytes or encounter an IOException.

* reaches it.
*/
internal class CapturedResponseBody(
private val delegate: ResponseBody,
private val maxBytes: Long,
private val onCaptured: (ByteArray) -> Unit,
) : ResponseBody() {

private val captured = Buffer()
private val reported = AtomicBoolean(false)

// A buffered source still forwards a short event on its own: one read takes at most one segment
// from the source below and returns with whatever arrived. Only request/require/readByteArray()
// wait for a byte count, and those are the application's own calls.
private val capturingSource: BufferedSource by lazy {
CapturingSource(delegate.source()).buffer()
}

override fun contentType(): MediaType? = delegate.contentType()

override fun contentLength(): Long = delegate.contentLength()

override fun source(): BufferedSource = capturingSource

override fun close() {
try {

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.

nit: we can replace try + finally with delegate.use if you like

// Closing the delegate notifies listeners, which may serialize the breadcrumb this capture
// belongs to, so report first.
reportCaptured()
} finally {
delegate.close()
}
}

private inner class CapturingSource(source: Source) : ForwardingSource(source) {
override fun read(sink: Buffer, byteCount: Long): Long {
val sinkBefore = sink.size
val read =
try {
super.read(sink, byteCount)
} catch (e: IOException) {
// What arrived is the evidence for this very failure, and a caller handling it is not
// obliged to close the body.

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.

nit: Fwiw, I can't quite figure out what the LLM was trying to say here.

reportCaptured()
throw e
}

if (read > 0L) {
val capFull =
synchronized(captured) {
// the application may not have taken everything in the sink yet, hence the offset
val toTake = minOf(maxBytes - captured.size, sink.size - sinkBefore)
if (toTake > 0L) {
sink.copyTo(captured, sinkBefore, toTake)

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.

โš ๏ธ I think we need to use the Buffer.copyTo() method that takes an OutputStream rather than the one that takes a Buffer.

Looks like Buffer.copyTo(Buffer, ...) doesn't immediately copy bytes, but instead (eventually) calls Segment.sharedCopy() (link). That means:

  1. each captured section will keep a reference to the sink segment's entire 8 KB byte array (link), and
  2. the segments won't be returned to the segment pool (link)

My LLM tells me that for SSE, long-poll, or chunked streams with small events, each small event will be forced to retain a full 8 KB array. With 10-byte heartbeats, that makes for ~120 MB of memory leaked per stream.

The Buffer.copyTo() method that takes an OutputStream doesn't share the segment, and so we avoid leaking it (link).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shared segments inflate capture memory

High Severity

Buffer.copyTo(Buffer) shares each Okio segment instead of copying bytes, so every small SSE/chunked read retains an entire 8 KB array. A stream that arrives as many small events can pin tens of megabytes until the cap is hit, and reportCaptured never clears captured, so that memory stays for the life of the body.

Additional Locations (1)
Fix in Cursorย Fix in Web

Reviewed by Cursor Bugbot for commit 4dbcbfa. Configure here.

}
captured.size >= maxBytes
}
if (capFull) {
reportCaptured()
}
} else if (read == -1L) {
reportCaptured()
}
return read
}

override fun close() {
try {
reportCaptured()
} finally {
super.close()
}
}
}

private fun reportCaptured() {
if (reported.compareAndSet(false, true)) {
onCaptured(synchronized(captured) { captured.clone().readByteArray() })
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import org.jetbrains.annotations.VisibleForTesting
* @param failedRequestTargets The SDK will only capture HTTP Client errors if the HTTP Request URL
* is a match for any of the defined targets.
*/
@Suppress("TooManyFunctions") // one function per concern of the interception pipeline
public open class SentryOkHttpInterceptor(
private val scopes: IScopes = ScopesAdapter.getInstance(),
private val beforeSpan: BeforeSpanCallback? = null,
Expand Down Expand Up @@ -195,6 +196,9 @@ public open class SentryOkHttpInterceptor(
}
}

// after the client error was captured from the untouched response
response = response.withNetworkBodyCapture(networkDetailData)

return response
} catch (e: IOException) {
span?.apply {
Expand All @@ -207,20 +211,6 @@ public open class SentryOkHttpInterceptor(
// this only works correctly if SentryOkHttpInterceptor is the last one in the chain
okHttpEvent?.setRequest(request)

response?.let {
networkDetailData?.setResponseDetails(
it.code,
NetworkDetailCaptureUtils.createResponse(
it,
it.body?.contentLength(),
scopes.options.sessionReplay.isNetworkCaptureBodies,
{ resp: Response -> resp.extractResponseBody(scopes.options.logger) },
scopes.options.sessionReplay.networkResponseHeaders,
{ resp: Response -> resp.headers.toMap() },
),
)
}

// Set network details on the OkHttpEvent so it can include them in the breadcrumb hint
okHttpEvent?.setNetworkDetails(networkDetailData)

Expand Down Expand Up @@ -326,36 +316,89 @@ public open class SentryOkHttpInterceptor(
}
}

/** Extracts the body content from an OkHttp Response safely */
private fun Response.extractResponseBody(logger: ILogger): NetworkBody? {
return body?.let { responseBody ->
try {
val contentType = responseBody.contentType()
val contentTypeString = contentType?.toString()
val maxBodySize = SentryReplayOptions.MAX_NETWORK_BODY_SIZE

// Peek at the body (doesn't consume it)
// We +1 here in order to properly truncate within NetworkBodyParser.fromBytes
// and be able to distinguish from an oversized request and a request matching maxBodySize
val peekBody = peekBody(maxBodySize.toLong() + 1)
val bodyBytes = peekBody.bytes()

val charset = contentType?.charset(Charsets.UTF_8)?.name() ?: "UTF-8"
return NetworkBodyParser.fromBytes(
bodyBytes,
contentTypeString,
charset,
maxBodySize,
logger,
)
} catch (e: Exception) {
logger.log(
io.sentry.SentryLevel.ERROR,
"Failed to read http response body for Network Details: ${e.message}",
)
null
}
/**
* Returns this response with a body whose content is captured for replay while the application
* reads it.
*
* Reading the body up front, as [Response.peekBody] does, deadlocks on a response of unknown
* length: server-sent events, a long-poll, a chunked endpoint that stays open, and every response
* OkHttp decompressed itself. The caller would never receive the response at all.
*
* Every body therefore goes through [CapturedResponseBody], which captures only what the
* application reads. A body nobody reads leaves the status code and the headers recorded below,
* and no body.
*/
private fun Response.withNetworkBodyCapture(networkDetailData: NetworkRequestData?): Response {
val responseBody = body
if (networkDetailData == null || responseBody == null) {
return this
}

val knownBodySize = responseBody.contentLength().takeIf { it >= 0L }

// The status code and the headers are known now, so record them before anything is consumed. A
// stream that stays open for minutes never gets further than this.
recordResponse(networkDetailData, knownBodySize) { null }

if (!scopes.options.sessionReplay.isNetworkCaptureBodies) {
return this
}

val logger = scopes.options.logger
// One byte above the limit, so NetworkBodyParser.fromBytes can tell a truncated body from one
// that happens to match the limit exactly.
val captureCap = SentryReplayOptions.MAX_NETWORK_BODY_SIZE.toLong() + 1
val contentType = responseBody.contentType()
val contentTypeString = contentType?.toString()
val charset = contentType?.charset(Charsets.UTF_8)?.name() ?: "UTF-8"

return newBuilder()
.body(
CapturedResponseBody(responseBody, captureCap) { capturedBytes ->
// This runs on the thread consuming the body, inside its read or close, so a failure in

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.

h: Thoughts about calling recordResponse() from our executor service?

I'm worried about delaying delivery of SSE events when the onCaptured callback here is invoked from CapturedResponseBody.read(), and causing app frames to stutter when called from .close() (as apps often cancel a stream or close a response from onDestroy()).

// here must stay in here.
try {
recordResponse(networkDetailData, knownBodySize ?: capturedBytes.size.toLong()) {
NetworkBodyParser.fromBytes(
capturedBytes,
contentTypeString,
charset,
SentryReplayOptions.MAX_NETWORK_BODY_SIZE,
logger,
)
}
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
ExceptionUtils.rethrowIfFatal(e)
logger.log(
io.sentry.SentryLevel.ERROR,
"Failed to capture the http response body for Network Details: ${e.message}",

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.

l: We could also log e to pick up the stack traces.

)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New broad Throwable catch added

Low Severity

This new catch (Throwable) wraps SDK code (recordResponse and NetworkBodyParser.fromBytes), not a user or third-party callback. The project rule allows that pattern only at those entry points, even when rethrowIfFatal is used.

Fix in Cursorย Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bugbot

Reviewed by Cursor Bugbot for commit 4dbcbfa. Configure here.

}
)
.build()
}

/**
* Records what is known about this response, replacing anything recorded for it before. Called
* once before the body is consumed and once with the captured body.
*/
private fun Response.recordResponse(
networkDetailData: NetworkRequestData,
bodySize: Long?,
body: () -> NetworkBody?,
) {
networkDetailData.setResponseDetails(
NetworkDetailCaptureUtils.createResponse(
this,
code,
bodySize,
true,
{ body() },
scopes.options.sessionReplay.networkResponseHeaders,
{ resp: Response -> resp.headers.toMap() },
)
)
}

private fun finishSpan(
Expand Down
Loading
Loading