Repository navigation
Conversation
Capturing a response body for session replay peeked it up front with `Response.peekBody(MAX_NETWORK_BODY_SIZE + 1)`. OkHttp implements a peek as `request(byteCount)`, which keeps reading until that many bytes are buffered or the stream ends. A response with no Content-Length never satisfies either condition, so for server-sent events, long-poll and any chunked endpoint that stays open the interceptor never returned and the caller never received the response at all. Bodies with a known length still end on their own, so they keep the existing up-front capture. Bodies of unknown length are now wrapped in a body that copies what the application consumes into a capped buffer and reports it once no more bytes can arrive: when the stream ends, when the application closes the body, or when the cap is reached. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the capture wrapping into its own function to keep a single return, and suppress TooManyFunctions on the interceptor as done elsewhere in this module.
A response body has a single consumer, and neither Http1ExchangeCodec.cancel() nor Http2ExchangeCodec.cancel() closes the body, so nothing reaches this class from another thread. The AtomicBoolean already guarantees the capture is reported exactly once.
A streamed body is only known once it has been consumed, which can be after the NetworkRequestData carrying it was handed to the scope, so the replay thread can read it while it is still being written. Keeping the three response values behind one volatile reference means a reader sees either nothing or the complete set.
8 of 9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
When Sentry captures a response body for Session Replay, it peeks at the body before handing the
response back to your code:
For a normal response this is fine — the body ends, so the peek returns. But OkHttp implements
"peek" as "keep reading until N bytes are buffered, or the stream ends":
A response with no
Content-Length— Server-Sent Events, a long-poll, a chunked endpoint thatstays open — never satisfies either condition. The interceptor sits in that read forever.
The consequence is worse than slow events: the application never receives the response at all.
It can't even open a reader, so nothing arrives, ever:
There are two ways it fails, depending on the server:
SocketTimeoutExceptionafter 10s, logged as an error, on every connectionIt only triggers when Session Replay network details are enabled for the URL
(
options.sessionReplay.setNetworkDetailAllowUrls(...)), which is why it is rare and easy to miss.The solution
You cannot capture a body of unknown length up front, so stop reading it up front.
A body of unknown length is wrapped in a body that copies the bytes as your code consumes them
into a capped buffer, and reports the capture once no more bytes can ever arrive — when the stream
ends, when your code closes the body, or when the capture cap is reached.
The application sees exactly the same bytes, in the same order, at the same speed. The wrapper only
copies what passes through and never reads on its own:
wrapper also allocates no more than having no interceptor at all
Responses with a known
Content-Lengthare untouched: they end on their own, so they keep theexisting up-front capture. That also means unread bodies with a known length — a 500 whose body you
only check the status of — are still captured exactly as before.
Why
NetworkRequestDatachangedCapturing a streamed body means filling in the response after the breadcrumb carrying it has
already been handed to the scope, so the replay thread can read it while it is being written. The
three response values therefore moved behind a single
volatilereference: a late reader seeseither nothing or the complete set, never a mix. Nothing else about that class changed, and the
request side is untouched.
Tests
NetworkBodyCapturingResponseBodyTestcovers the wrapper in isolation: nothing is read up front,bytes are forwarded unchanged, small events arrive one at a time, capture is reported on stream end
and on close (exactly once), an empty body reports an empty capture, the cap is enforced without
truncating what the application reads, read failures propagate, and the delegate body is closed.
SentryOkHttpInterceptorStreamingTestdrives a real chunked HTTP origin (MockWebServer cannotexpress a body that never ends) through the interceptor:
returns the response even though the body never ends— the regression test. Without the fix itfails with a
TimeoutException; with it,execute()returns immediately.delivers each small event to the application as it arrives— three 15-byte events, each read bythe application separately, in order
captures a streamed body once the stream ends/...when the application closes itcaptures a streamed error response body(500)caps the captured body without truncating the body the application reads(200 KB through a150 KB cap)
handles a zero length bodyandhandles a zero length streamed bodydoes not capture bodies when network body capture is turned offstill captures a response with a known length up frontandstill captures an error response with a known length the application never reads— the existingbehaviour is pinned
keeps the connection usable after a streamed response is closedNetworkRequestDataTestpins the observable contract: empty until set, all three readable once set,and fillable after the instance was published.
Full
:sentry-okhttp:testrun: 129 tests, no new failures (the 22 failures present are apre-existing JDK 25 / Mockito-inline issue on this machine, identical on unmodified
main).:sentry:spotlessCheckand:sentry-okhttp:detektpass.Trade-offs, stated up front
inside the exchange on
close(), below the wrapper, so those bytes never pass through it. Theresponse itself (status, size, headers) is still recorded. This only affects bodies of unknown
length; known-length bodies behave as before.
that called
execute()/enqueue(). For streaming responses that thread is a background thread bydefinition, and the work is the same parsing that already happened before, only later.
MAX_NETWORK_BODY_SIZEper in-flight response until the body is closed.NetworkRequestDatais a JMM correctness change and is not covered bya race test: a stress test with four readers and two million writes could not observe the torn
state on this JVM, so the project has no test that would fail without the
volatile.