Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixes

- Session Replay: Fix network detail response body size being unknown for gzip-compressed responses ([#5592](https://github.com/getsentry/sentry-java/pull/5592))

### Behavioral Changes

- 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))
Expand Down
15 changes: 14 additions & 1 deletion sentry/src/main/java/io/sentry/util/network/NetworkBody.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,24 @@ public final class NetworkBody {

private final @Nullable Object body;
private final @Nullable List<NetworkBodyWarning> warnings;
private final long originalByteCount;

public NetworkBody(final @Nullable Object body) {
this(body, null);
this(body, null, -1);
}

public NetworkBody(
final @Nullable Object body, final @Nullable List<NetworkBodyWarning> warnings) {
this(body, warnings, -1);
}

NetworkBody(
final @Nullable Object body,
final @Nullable List<NetworkBodyWarning> warnings,
final long originalByteCount) {
Comment thread
romtsn marked this conversation as resolved.
this.body = body;
this.warnings = warnings;
this.originalByteCount = originalByteCount;
}

public @Nullable Object getBody() {
Expand All @@ -35,6 +44,10 @@ public NetworkBody(
return warnings;
}

long getOriginalByteCount() {
return originalByteCount;
}

// Based on
// https://github.com/getsentry/sentry/blob/ccb61aa9b0f33e1333830093a5ce3bd5db88ef33/static/app/utils/replays/replay.tsx#L5-L12
public enum NetworkBodyWarning {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,33 @@ private NetworkBodyParser() {}
return null;
}

final boolean isTruncated = bytes.length > maxSizeBytes;
final long originalByteCount = bytes.length;

if (contentType != null && isBinaryContentType(contentType)) {
// For binary content, return a description instead of the actual content
return new NetworkBody(
"[Binary data, " + bytes.length + " bytes, type: " + contentType + "]");
"[Binary data, " + bytes.length + " bytes, type: " + contentType + "]",
null,
originalByteCount);
}

// Convert to string and parse
try {
final String effectiveCharset = charset != null ? charset : "UTF-8";
final int size = Math.min(bytes.length, maxSizeBytes);
final boolean isPartial = bytes.length > maxSizeBytes;
final String content = new String(bytes, 0, size, effectiveCharset);
return parse(content, contentType, isPartial, logger);
final NetworkBody parsed = parse(content, contentType, isTruncated, logger);
if (parsed == null) {
return null;
}
return new NetworkBody(parsed.getBody(), parsed.getWarnings(), originalByteCount);
} catch (UnsupportedEncodingException e) {
logger.log(SentryLevel.WARNING, "Failed to decode bytes: " + e.getMessage());
return new NetworkBody(
"[Failed to decode bytes, " + bytes.length + " bytes]",
Collections.singletonList(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR));
Collections.singletonList(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR),
originalByteCount);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,15 @@ private static boolean shouldCaptureUrl(
body = bodyExtractor.extract(httpObject);
}

// When contentLength is unknown (-1), use the actual byte count from body extraction
Long effectiveBodySize = bodySize;
if ((bodySize == null || bodySize == -1L) && body != null && body.getOriginalByteCount() >= 0) {
effectiveBodySize = body.getOriginalByteCount();
}

Map<String, String> headers =
getCaptureHeaders(headerExtractor.extract(httpObject), allowedHeaders);

return new ReplayNetworkRequestOrResponse(bodySize, body, headers);
return new ReplayNetworkRequestOrResponse(effectiveBodySize, body, headers);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,27 @@ class NetworkBodyParserTest {
val body = NetworkBodyParser.fromBytes(bytes, "image/png", null, bytes.size, logger)
assertNotNull(body)
assertEquals("[Binary data, 100 bytes, type: image/png]", body.body)
assertEquals(100, body.originalByteCount)
}

@Test
fun `originalByteCount is set when body fits within limit`() {
val logger = mock<ILogger>()
val bytes = """{"key":"value"}""".toByteArray()

val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger)
assertNotNull(body)
assertEquals(bytes.size.toLong(), body.originalByteCount)
}

@Test
fun `originalByteCount is set to capped size when body is truncated`() {
val logger = mock<ILogger>()
val bytes = """{"key":"value"}""".toByteArray()

val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size - 1, logger)
assertNotNull(body)
assertEquals(bytes.size.toLong(), body.originalByteCount)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,70 @@
package io.sentry.util.network

import io.sentry.ILogger
import java.util.LinkedHashMap
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.Test
import org.mockito.kotlin.mock

class NetworkDetailCaptureUtilsTest {

@Test
fun `createResponse uses originalByteCount when bodySize is unknown`() {
val logger = mock<ILogger>()
val jsonBytes = """{"key":"value"}""".toByteArray()

val result =
NetworkDetailCaptureUtils.createResponse(
jsonBytes,
-1L,
true,
{ bytes ->
NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger)
},
emptyList(),
{ emptyMap() },
)

assertEquals(jsonBytes.size.toLong(), result.size)
}

@Test
fun `createResponse keeps explicit bodySize when available`() {
val logger = mock<ILogger>()
val jsonBytes = """{"key":"value"}""".toByteArray()

val result =
NetworkDetailCaptureUtils.createResponse(
jsonBytes,
42L,
true,
{ bytes ->
NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger)
},
emptyList(),
{ emptyMap() },
)

assertEquals(42L, result.size)
}

@Test
fun `createResponse keeps null bodySize when body capture is off`() {
val result =
NetworkDetailCaptureUtils.createResponse(
"unused",
null,
false,
{ null },
emptyList(),
{ emptyMap() },
)

assertNull(result.size)
}

@Test
fun `getCaptureHeaders should match headers case-insensitively`() {
// Setup: allHeaders with mixed case keys
Expand Down
Loading