Skip to content

Commit d500866

Browse files
romtsnclaude
andauthored
fix(replay): Fix network detail response body size being unknown for gzip-compressed responses (#5592)
* fix(replay): Derive response body size from peeked bytes when contentLength is unknown For gzip-compressed responses, OkHttp strips the Content-Length header during transparent decompression, so response.body.contentLength() returns -1. This caused NetworkRequestData.responseBodySize to be unknown for replay network details. Add originalByteCount to NetworkBody, set it from the actual byte array in NetworkBodyParser.fromBytes, and use it as a fallback in NetworkDetailCaptureUtils when the passed bodySize is null or -1. This piggybacks on the existing peekBody call with no additional I/O. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * changelog * Changelog * fix(replay): make NetworkBody 3-arg constructor package-private Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4f4801e commit d500866

6 files changed

Lines changed: 117 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 8.46.0
44

5+
### Fixes
6+
7+
- Session Replay: Fix network detail response body size being unknown for gzip-compressed responses ([#5592](https://github.com/getsentry/sentry-java/pull/5592))
8+
59
### Behavioral Changes
610

711
- Collections returned by scope (e.g. `getBreadcrumbs`, `getTags`, `getAttachments`) are shared state and should not be mutated. ([#5541](https://github.com/getsentry/sentry-java/pull/5541))

‎sentry/src/main/java/io/sentry/util/network/NetworkBody.java‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,24 @@ public final class NetworkBody {
1616

1717
private final @Nullable Object body;
1818
private final @Nullable List<NetworkBodyWarning> warnings;
19+
private final long originalByteCount;
1920

2021
public NetworkBody(final @Nullable Object body) {
21-
this(body, null);
22+
this(body, null, -1);
2223
}
2324

2425
public NetworkBody(
2526
final @Nullable Object body, final @Nullable List<NetworkBodyWarning> warnings) {
27+
this(body, warnings, -1);
28+
}
29+
30+
NetworkBody(
31+
final @Nullable Object body,
32+
final @Nullable List<NetworkBodyWarning> warnings,
33+
final long originalByteCount) {
2634
this.body = body;
2735
this.warnings = warnings;
36+
this.originalByteCount = originalByteCount;
2837
}
2938

3039
public @Nullable Object getBody() {
@@ -35,6 +44,10 @@ public NetworkBody(
3544
return warnings;
3645
}
3746

47+
long getOriginalByteCount() {
48+
return originalByteCount;
49+
}
50+
3851
// Based on
3952
// https://github.com/getsentry/sentry/blob/ccb61aa9b0f33e1333830093a5ce3bd5db88ef33/static/app/utils/replays/replay.tsx#L5-L12
4053
public enum NetworkBodyWarning {

‎sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,24 +45,33 @@ private NetworkBodyParser() {}
4545
return null;
4646
}
4747

48+
final boolean isTruncated = bytes.length > maxSizeBytes;
49+
final long originalByteCount = bytes.length;
50+
4851
if (contentType != null && isBinaryContentType(contentType)) {
4952
// For binary content, return a description instead of the actual content
5053
return new NetworkBody(
51-
"[Binary data, " + bytes.length + " bytes, type: " + contentType + "]");
54+
"[Binary data, " + bytes.length + " bytes, type: " + contentType + "]",
55+
null,
56+
originalByteCount);
5257
}
5358

5459
// Convert to string and parse
5560
try {
5661
final String effectiveCharset = charset != null ? charset : "UTF-8";
5762
final int size = Math.min(bytes.length, maxSizeBytes);
58-
final boolean isPartial = bytes.length > maxSizeBytes;
5963
final String content = new String(bytes, 0, size, effectiveCharset);
60-
return parse(content, contentType, isPartial, logger);
64+
final NetworkBody parsed = parse(content, contentType, isTruncated, logger);
65+
if (parsed == null) {
66+
return null;
67+
}
68+
return new NetworkBody(parsed.getBody(), parsed.getWarnings(), originalByteCount);
6169
} catch (UnsupportedEncodingException e) {
6270
logger.log(SentryLevel.WARNING, "Failed to decode bytes: " + e.getMessage());
6371
return new NetworkBody(
6472
"[Failed to decode bytes, " + bytes.length + " bytes]",
65-
Collections.singletonList(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR));
73+
Collections.singletonList(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR),
74+
originalByteCount);
6675
}
6776
}
6877

‎sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,15 @@ private static boolean shouldCaptureUrl(
160160
body = bodyExtractor.extract(httpObject);
161161
}
162162

163+
// When contentLength is unknown (-1), use the actual byte count from body extraction
164+
Long effectiveBodySize = bodySize;
165+
if ((bodySize == null || bodySize == -1L) && body != null && body.getOriginalByteCount() >= 0) {
166+
effectiveBodySize = body.getOriginalByteCount();
167+
}
168+
163169
Map<String, String> headers =
164170
getCaptureHeaders(headerExtractor.extract(httpObject), allowedHeaders);
165171

166-
return new ReplayNetworkRequestOrResponse(bodySize, body, headers);
172+
return new ReplayNetworkRequestOrResponse(effectiveBodySize, body, headers);
167173
}
168174
}

‎sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,27 @@ class NetworkBodyParserTest {
341341
val body = NetworkBodyParser.fromBytes(bytes, "image/png", null, bytes.size, logger)
342342
assertNotNull(body)
343343
assertEquals("[Binary data, 100 bytes, type: image/png]", body.body)
344+
assertEquals(100, body.originalByteCount)
345+
}
346+
347+
@Test
348+
fun `originalByteCount is set when body fits within limit`() {
349+
val logger = mock<ILogger>()
350+
val bytes = """{"key":"value"}""".toByteArray()
351+
352+
val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger)
353+
assertNotNull(body)
354+
assertEquals(bytes.size.toLong(), body.originalByteCount)
355+
}
356+
357+
@Test
358+
fun `originalByteCount is set to capped size when body is truncated`() {
359+
val logger = mock<ILogger>()
360+
val bytes = """{"key":"value"}""".toByteArray()
361+
362+
val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size - 1, logger)
363+
assertNotNull(body)
364+
assertEquals(bytes.size.toLong(), body.originalByteCount)
344365
}
345366

346367
@Test

‎sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,70 @@
11
package io.sentry.util.network
22

3+
import io.sentry.ILogger
34
import java.util.LinkedHashMap
45
import kotlin.test.assertEquals
6+
import kotlin.test.assertNull
57
import kotlin.test.assertTrue
68
import org.junit.Test
9+
import org.mockito.kotlin.mock
710

811
class NetworkDetailCaptureUtilsTest {
912

13+
@Test
14+
fun `createResponse uses originalByteCount when bodySize is unknown`() {
15+
val logger = mock<ILogger>()
16+
val jsonBytes = """{"key":"value"}""".toByteArray()
17+
18+
val result =
19+
NetworkDetailCaptureUtils.createResponse(
20+
jsonBytes,
21+
-1L,
22+
true,
23+
{ bytes ->
24+
NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger)
25+
},
26+
emptyList(),
27+
{ emptyMap() },
28+
)
29+
30+
assertEquals(jsonBytes.size.toLong(), result.size)
31+
}
32+
33+
@Test
34+
fun `createResponse keeps explicit bodySize when available`() {
35+
val logger = mock<ILogger>()
36+
val jsonBytes = """{"key":"value"}""".toByteArray()
37+
38+
val result =
39+
NetworkDetailCaptureUtils.createResponse(
40+
jsonBytes,
41+
42L,
42+
true,
43+
{ bytes ->
44+
NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger)
45+
},
46+
emptyList(),
47+
{ emptyMap() },
48+
)
49+
50+
assertEquals(42L, result.size)
51+
}
52+
53+
@Test
54+
fun `createResponse keeps null bodySize when body capture is off`() {
55+
val result =
56+
NetworkDetailCaptureUtils.createResponse(
57+
"unused",
58+
null,
59+
false,
60+
{ null },
61+
emptyList(),
62+
{ emptyMap() },
63+
)
64+
65+
assertNull(result.size)
66+
}
67+
1068
@Test
1169
fun `getCaptureHeaders should match headers case-insensitively`() {
1270
// Setup: allHeaders with mixed case keys

0 commit comments

Comments
 (0)