diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d66d755a7c..f462f55af22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ ### Fixes -- Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) +- Session Replay: Fix network detail response body size being unknown for gzip-compressed responses ([#5592](https://github.com/getsentry/sentry-java/pull/5592)) +- Session Replay: Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) ## 8.44.1 diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e9083350349..ef2ea5e293b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7975,7 +7975,9 @@ public final class io/sentry/util/UrlUtils$UrlDetails { public final class io/sentry/util/network/NetworkBody { public fun (Ljava/lang/Object;)V public fun (Ljava/lang/Object;Ljava/util/List;)V + public fun (Ljava/lang/Object;Ljava/util/List;J)V public fun getBody ()Ljava/lang/Object; + public fun getOriginalByteCount ()J public fun getWarnings ()Ljava/util/List; public fun toString ()Ljava/lang/String; } diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkBody.java b/sentry/src/main/java/io/sentry/util/network/NetworkBody.java index 5b4f6365ad4..455de9b36e2 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkBody.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkBody.java @@ -16,15 +16,24 @@ public final class NetworkBody { private final @Nullable Object body; private final @Nullable List 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 warnings) { + this(body, warnings, -1); + } + + public NetworkBody( + final @Nullable Object body, + final @Nullable List warnings, + final long originalByteCount) { this.body = body; this.warnings = warnings; + this.originalByteCount = originalByteCount; } public @Nullable Object getBody() { @@ -35,6 +44,10 @@ public NetworkBody( return warnings; } + public long getOriginalByteCount() { + return originalByteCount; + } + // Based on // https://github.com/getsentry/sentry/blob/ccb61aa9b0f33e1333830093a5ce3bd5db88ef33/static/app/utils/replays/replay.tsx#L5-L12 public enum NetworkBodyWarning { diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java index 49325a99003..42df5ca35b9 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java @@ -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); } } diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java index e0438c375b1..f5134693e00 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -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 headers = getCaptureHeaders(headerExtractor.extract(httpObject), allowedHeaders); - return new ReplayNetworkRequestOrResponse(bodySize, body, headers); + return new ReplayNetworkRequestOrResponse(effectiveBodySize, body, headers); } } diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt index 3b1da25a0c2..04a19d47712 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt @@ -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() + 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() + 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 diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt index cf4ec4828ff..25b142af7e9 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt @@ -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() + 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() + 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