From 667e61a55c06ddf4bacb7eeee98deca11700f881 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Wed, 19 Nov 2025 13:29:45 -0400 Subject: [PATCH 01/16] Use case-insensitive comparision when extracting headers Run tests ./gradlew :sentry:test --tests="*NetworkDetailCaptureUtilsTest*" --- .../network/NetworkDetailCaptureUtils.java | 24 ++++-- .../network/NetworkDetailCaptureUtilsTest.kt | 81 +++++++++++++++++++ 2 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt 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 3ed8e1e1674..21fcf2d0a35 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -1,9 +1,12 @@ package io.sentry.util.network; -import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; /** * Utility class for network capture operations shared across HTTP client integrations. Provides @@ -115,19 +118,24 @@ private static boolean shouldCaptureUrl( return false; } - private static @NotNull Map getCaptureHeaders( + @VisibleForTesting + static @NotNull Map getCaptureHeaders( @Nullable final Map allHeaders, @NotNull final String[] allowedHeaders) { - Map capturedHeaders = new HashMap<>(); - - if (allHeaders == null) { + final Map capturedHeaders = new LinkedHashMap<>(); + if (allHeaders == null || allowedHeaders.length == 0) { return capturedHeaders; } + // Convert to lowercase for case-insensitive matching + Set normalizedAllowed = new HashSet<>(); for (String header : allowedHeaders) { - String value = allHeaders.get(header); - if (value != null) { - capturedHeaders.put(header, value); + normalizedAllowed.add(header.toLowerCase()); + } + + for (Map.Entry entry : allHeaders.entrySet()) { + if (normalizedAllowed.contains(entry.getKey().toLowerCase())) { + capturedHeaders.put(entry.getKey(), entry.getValue()); } } diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt new file mode 100644 index 00000000000..94a5398acd5 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt @@ -0,0 +1,81 @@ +package io.sentry.util.network + +import java.util.LinkedHashMap +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Test + +class NetworkDetailCaptureUtilsTest { + + @Test + fun `getCaptureHeaders should match headers case-insensitively`() { + // Setup: allHeaders with mixed case keys + val allHeaders = + LinkedHashMap().apply { + put("Content-Type", "application/json") + put("Authorization", "Bearer token123") + put("X-Custom-Header", "custom-value") + put("accept", "application/json") + } + + // Test: allowedHeaders with different casing + val allowedHeaders = arrayOf("content-type", "AUTHORIZATION", "x-custom-header", "ACCEPT") + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + + // All headers should be matched despite case differences + assertEquals(4, result.size) + + // Original casing should be preserved in output + assertEquals("application/json", result["Content-Type"]) + assertEquals("Bearer token123", result["Authorization"]) + assertEquals("custom-value", result["X-Custom-Header"]) + assertEquals("application/json", result["accept"]) + + // Verify keys maintain original casing from allHeaders + assertTrue(result.containsKey("Content-Type")) + assertTrue(result.containsKey("Authorization")) + assertTrue(result.containsKey("X-Custom-Header")) + assertTrue(result.containsKey("accept")) + } + + @Test + fun `getCaptureHeaders should handle null allHeaders`() { + val allowedHeaders = arrayOf("content-type") + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(null, allowedHeaders) + + assertTrue(result.isEmpty()) + } + + @Test + fun `getCaptureHeaders should handle empty allowedHeaders`() { + val allHeaders = mapOf("Content-Type" to "application/json") + val allowedHeaders = arrayOf() + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + + assertTrue(result.isEmpty()) + } + + @Test + fun `getCaptureHeaders should only capture allowed headers`() { + val allHeaders = + mapOf( + "Content-Type" to "application/json", + "Authorization" to "Bearer token123", + "X-Unwanted-Header" to "should-not-appear", + ) + + val allowedHeaders = arrayOf("content-type", "authorization") + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + + assertEquals(2, result.size) + assertEquals("application/json", result["Content-Type"]) + assertEquals("Bearer token123", result["Authorization"]) + + // Unwanted header should not be present + assertTrue(!result.containsKey("X-Unwanted-Header")) + } +} From 83a19039f0bf2945676310e19d0d3bc2503aa860 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Wed, 19 Nov 2025 14:29:17 -0400 Subject: [PATCH 02/16] Extract network details when using SentryOkHttpEventListener Reuse existing logic that retrieves optional SentryOkHttpEvent for the okhttp3.Call, and optionally provide NetworkRequestData for adding to Breadcrumb Hint in SentryOkHttpEvent#finish --- .../main/java/io/sentry/okhttp/SentryOkHttpEvent.kt | 10 ++++++++++ .../java/io/sentry/okhttp/SentryOkHttpInterceptor.kt | 3 +++ .../samples/android/TriggerHttpRequestActivity.java | 7 ++++--- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt index fc894ec3768..7475f09443b 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt @@ -10,6 +10,7 @@ import io.sentry.TypeCheckHint import io.sentry.transport.CurrentDateProvider import io.sentry.util.Platform import io.sentry.util.UrlUtils +import io.sentry.util.network.NetworkRequestData import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -27,6 +28,7 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques internal val callSpan: ISpan? private var response: Response? = null private var clientErrorResponse: Response? = null + private var networkDetails: NetworkRequestData? = null internal val isEventFinished = AtomicBoolean(false) private var url: String private var method: String @@ -135,6 +137,11 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques } } + /** Sets the [NetworkRequestData] for network detail capture. */ + fun setNetworkDetails(networkRequestData: NetworkRequestData?) { + this.networkDetails = networkRequestData + } + /** Record event start if the callRootSpan is not null. */ fun onEventStart(event: String) { callSpan ?: return @@ -163,6 +170,9 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques hint.set(TypeCheckHint.OKHTTP_REQUEST, request) response?.let { hint.set(TypeCheckHint.OKHTTP_RESPONSE, it) } + // Include network details in the hint for session replay + networkDetails?.let { hint.set(TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS, it) } + // needs this as unix timestamp for rrweb breadcrumb.setData( SpanDataConvention.HTTP_END_TIMESTAMP, diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index bfcc5a033d4..5fa839ca891 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -209,6 +209,9 @@ public open class SentryOkHttpInterceptor( ) } + // Set network details on the OkHttpEvent so it can include them in the breadcrumb hint + okHttpEvent?.setNetworkDetails(networkDetailData) + finishSpan(span, request, response, isFromEventListener, okHttpEvent) // The SentryOkHttpEventListener will send the breadcrumb itself if used for this call diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java index 486b558a717..5671a044437 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TriggerHttpRequestActivity.java @@ -10,6 +10,7 @@ import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import io.sentry.Sentry; +import io.sentry.okhttp.SentryOkHttpEventListener; import io.sentry.okhttp.SentryOkHttpInterceptor; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -80,14 +81,14 @@ private void initializeViews() { private void setupOkHttpClient() { // OkHttpClient with Sentry integration for monitoring HTTP requests + // Both SentryOkHttpEventListener and SentryOkHttpInterceptor are enabled to test + // network detail capture when both components are used together okHttpClient = new OkHttpClient.Builder() .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS) - // performance monitoring - // .eventListener(new SentryOkHttpEventListener()) - // breadcrumbs and failed request capture + .eventListener(new SentryOkHttpEventListener()) .addInterceptor(new SentryOkHttpInterceptor()) .build(); } From a3ecf3b1147130a21835d3d2c1db9bfdad0330a6 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Wed, 19 Nov 2025 16:03:00 -0400 Subject: [PATCH 03/16] CHANGELOG for Network Details extraction --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48568805a36..cc03d72a77e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ ### Features +- Enable capturing additional network details in your session replays (okhttp). + - Depends on `SentryOkHttpInterceptor` to intercept the request and extract request/response bodies. + - To enable, configure your sentry SDK using the "io.sentry.session-replay.network-*" options via [manifest](https://github.com/getsentry/sentry-java/blob/b03edbb1b0d8b871c62a09bc02cbd8a4e1f6fea1/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml#L196-L205) + - Or manually specify SentryReplayOptions via [SentryAndroid#init](https://github.com/getsentry/sentry-java/blob/c83e427e8baca17098f882f8b45fc7c5a80c1d8c/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java#L16-L28) - Implement OpenFeature Integration that tracks Feature Flag evaluations ([#4910](https://github.com/getsentry/sentry-java/pull/4910)) - To make use of it, add the `sentry-openfeature` dependency and register the the hook using: `openFeatureApiInstance.addHooks(new SentryOpenFeatureHook());` - Implement LaunchDarkly Integrations that track Feature Flag evaluations ([#4917](https://github.com/getsentry/sentry-java/pull/4917)) From 707eed48ce1702eadd563f96192ef541f94cd6e0 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Fri, 21 Nov 2025 13:26:03 -0400 Subject: [PATCH 04/16] unit tests ./gradlew :sentry-okhttp:test --tests="*SentryOkHttpEventTest*setNetworkDetails*" --- .../io/sentry/okhttp/SentryOkHttpEventTest.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt index ff671ac153f..d93457de6f9 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt @@ -15,6 +15,7 @@ import io.sentry.TransactionContext import io.sentry.TypeCheckHint import io.sentry.exception.SentryHttpClientException import io.sentry.test.getProperty +import io.sentry.util.network.NetworkRequestData import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -425,6 +426,34 @@ class SentryOkHttpEventTest { verify(fixture.scopes, never()).captureEvent(any(), any()) } + @Test + fun `when finish is called, the breadcrumb sent includes network details data on its hint`() { + val sut = fixture.getSut() + val networkRequestData = NetworkRequestData("GET") + + sut.setNetworkDetails(networkRequestData) + sut.finish() + + verify(fixture.scopes) + .addBreadcrumb( + any(), + check { assertEquals(networkRequestData, it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) } + ) + } + + @Test + fun `when setNetworkDetails is not called, no network details data is captured`() { + val sut = fixture.getSut() + + sut.finish() + + verify(fixture.scopes) + .addBreadcrumb( + any(), + check { assertNull(it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) } + ) + } + /** Retrieve all the spans started in the event using reflection. */ private fun SentryOkHttpEvent.getEventDates() = getProperty>("eventDates") From 90a1e0a5f4219d52d5cdff3c6207a40da9597600 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 21 Nov 2025 17:29:20 +0000 Subject: [PATCH 05/16] Format code --- .../src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt index d93457de6f9..5570e37787b 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt @@ -437,7 +437,7 @@ class SentryOkHttpEventTest { verify(fixture.scopes) .addBreadcrumb( any(), - check { assertEquals(networkRequestData, it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) } + check { assertEquals(networkRequestData, it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) }, ) } @@ -450,7 +450,7 @@ class SentryOkHttpEventTest { verify(fixture.scopes) .addBreadcrumb( any(), - check { assertNull(it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) } + check { assertNull(it[TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS]) }, ) } From a5e6d65291970cc41d6733bca41731630abaeae9 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Fri, 21 Nov 2025 16:52:09 -0400 Subject: [PATCH 06/16] bug: fix NullPointerException if allowedHeaders contains null review comment - https://github.com/getsentry/sentry-java/pull/4919/files#r2550496731 seems possible, e.g. if a client passes null in the array to SentryReplayOptions#set[Request|Response]Headers --- .../network/NetworkDetailCaptureUtils.java | 4 +++- .../network/NetworkDetailCaptureUtilsTest.kt | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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 21fcf2d0a35..1fd2b32b7cc 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -130,7 +130,9 @@ private static boolean shouldCaptureUrl( // Convert to lowercase for case-insensitive matching Set normalizedAllowed = new HashSet<>(); for (String header : allowedHeaders) { - normalizedAllowed.add(header.toLowerCase()); + if (header != null) { + normalizedAllowed.add(header.toLowerCase()); + } } for (Map.Entry entry : allHeaders.entrySet()) { 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 94a5398acd5..c3b3faef61f 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt @@ -78,4 +78,27 @@ class NetworkDetailCaptureUtilsTest { // Unwanted header should not be present assertTrue(!result.containsKey("X-Unwanted-Header")) } + + @Test + fun `getCaptureHeaders should handle null elements in allowedHeaders`() { + val allHeaders = + mapOf( + "Content-Type" to "application/json", + "Authorization" to "Bearer token123", + "X-Custom-Header" to "custom-value", + ) + + // allowedHeaders contains null elements which should be ignored + val allowedHeaders = arrayOf(null, "content-type", null, "authorization", null) + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + + // Only non-null allowed headers should be matched + assertEquals(2, result.size) + assertEquals("application/json", result["Content-Type"]) + assertEquals("Bearer token123", result["Authorization"]) + + // X-Custom-Header should not be present as it's not in the allowed list + assertTrue(!result.containsKey("X-Custom-Header")) + } } From 7098121eb0c17e7604bdca3b4a631296e47c1752 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Fri, 21 Nov 2025 17:48:50 -0400 Subject: [PATCH 07/16] bug: fix Duplicate HTTP headers lost in conversion review comment - https://github.com/getsentry/sentry-java/pull/4919#pullrequestreview-3484575241 ./gradlew :sentry-okhttp:test --tests="*SentryOkHttpInterceptorTest.toMap handles duplicate headers correctly*" --- .../sentry/okhttp/SentryOkHttpInterceptor.kt | 14 ++++++-- .../okhttp/SentryOkHttpInterceptorTest.kt | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index 5fa839ca891..2ede10d9e50 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -33,6 +33,7 @@ import okhttp3.Interceptor import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import org.jetbrains.annotations.VisibleForTesting /** * The Sentry's [SentryOkHttpInterceptor], it will automatically add a breadcrumb and start a span @@ -263,10 +264,19 @@ public open class SentryOkHttpInterceptor( } /** Extracts headers from OkHttp Headers object into a map */ - private fun okhttp3.Headers.toMap(): Map { + @VisibleForTesting + internal fun okhttp3.Headers.toMap(): Map { val headers = linkedMapOf() for (i in 0 until size) { - headers[name(i)] = value(i) + val name = name(i) + val value = value(i) + val existingValue = headers[name] + if (existingValue != null) { + // Concatenate duplicate headers with comma separator + headers[name] = "$existingValue, $value" + } else { + headers[name] = value + } } return headers } diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index bbdb3a86516..a0d18c36e6b 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -680,4 +680,39 @@ class SentryOkHttpInterceptorTest { assertNotNull(recordedRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER)) assertNull(recordedRequest.getHeader(W3CTraceparentHeader.TRACEPARENT_HEADER)) } + + @Test + fun `toMap handles duplicate headers correctly`() { + // Create a response with duplicate headers + val mockResponse = + MockResponse() + .setResponseCode(200) + .setBody("test") + .addHeader("Set-Cookie", "sessionId=123") + .addHeader("Set-Cookie", "userId=456") + .addHeader("Set-Cookie", "theme=dark") + .addHeader("Accept", "text/html") + .addHeader("Accept", "application/json") + .addHeader("Single-Header", "value") + + fixture.server.enqueue(mockResponse) + + // Execute request to get response with headers + val sut = fixture.getSut() + val response = sut.newCall(getRequest()).execute() + val headers = response.headers + + // Optional: verify OkHttp preserves duplicate headers + assertEquals(3, headers.values("Set-Cookie").size) + assertEquals(2, headers.values("Accept").size) + assertEquals(1, headers.values("Single-Header").size) + + val interceptor = SentryOkHttpInterceptor(fixture.scopes) + val headerMap = with(interceptor) { headers.toMap() } + + // Duplicate headers will be collapsed into 1 concatenated entry with ", " separator + assertEquals("sessionId=123, userId=456, theme=dark", headerMap["Set-Cookie"]) + assertEquals("text/html, application/json", headerMap["Accept"]) + assertEquals("value", headerMap["Single-Header"]) + } } From 24d0cbb368565ab5ad294e9a2d4df09123d97c08 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Fri, 21 Nov 2025 18:03:54 -0400 Subject: [PATCH 08/16] bug: fix Set-Cookie headers incorrectly concatenated with comma separator Issue is that commas are valid separators in certain headers (Cookie, Set-Cookie,...). Switch to semi-colon separated instead -> this only governs the formatted list that appears in the sentry dashboard so is relatively minor. review comment - https://github.com/getsentry/sentry-java/pull/4919#discussion_r2551063405 ./gradlew :sentry-okhttp:test --tests="*SentryOkHttpInterceptorTest.toMap handles duplicate headers correctly*" --- .../main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt | 4 ++-- .../java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index 2ede10d9e50..af7b25a782e 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -272,8 +272,8 @@ public open class SentryOkHttpInterceptor( val value = value(i) val existingValue = headers[name] if (existingValue != null) { - // Concatenate duplicate headers with comma separator - headers[name] = "$existingValue, $value" + // Concatenate duplicate headers with semicolon separator + headers[name] = "$existingValue; $value" } else { headers[name] = value } diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index a0d18c36e6b..6e8b8548731 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -710,9 +710,9 @@ class SentryOkHttpInterceptorTest { val interceptor = SentryOkHttpInterceptor(fixture.scopes) val headerMap = with(interceptor) { headers.toMap() } - // Duplicate headers will be collapsed into 1 concatenated entry with ", " separator - assertEquals("sessionId=123, userId=456, theme=dark", headerMap["Set-Cookie"]) - assertEquals("text/html, application/json", headerMap["Accept"]) + // Duplicate headers will be collapsed into 1 concatenated entry with "; " separator + assertEquals("sessionId=123; userId=456; theme=dark", headerMap["Set-Cookie"]) + assertEquals("text/html; application/json", headerMap["Accept"]) assertEquals("value", headerMap["Single-Header"]) } } From a4dd41c7659feacfd208c6cab4d77b2b35db1cbb Mon Sep 17 00:00:00 2001 From: Matthew Jay Williams <31186619+43jay@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:24:12 -0400 Subject: [PATCH 09/16] Update CHANGELOG.md based on review feedback Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc03d72a77e..ca9703b0761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ ### Features -- Enable capturing additional network details in your session replays (okhttp). +- Add option to capture additional network details for session replays (OkHttp) ([#4919](https://github.com/getsentry/sentry-java/pull/4919) - Depends on `SentryOkHttpInterceptor` to intercept the request and extract request/response bodies. - To enable, configure your sentry SDK using the "io.sentry.session-replay.network-*" options via [manifest](https://github.com/getsentry/sentry-java/blob/b03edbb1b0d8b871c62a09bc02cbd8a4e1f6fea1/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml#L196-L205) - Or manually specify SentryReplayOptions via [SentryAndroid#init](https://github.com/getsentry/sentry-java/blob/c83e427e8baca17098f882f8b45fc7c5a80c1d8c/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java#L16-L28) From f75c4f988b84189c542a82c65bb7a2fe181aed8f Mon Sep 17 00:00:00 2001 From: Matthew Jay Williams <31186619+43jay@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:25:05 -0400 Subject: [PATCH 10/16] 1/2 Respect default Locale when lowercasing header strings Co-authored-by: Markus Hintersteiner --- .../java/io/sentry/util/network/NetworkDetailCaptureUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1fd2b32b7cc..fb44423fcd2 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -136,7 +136,7 @@ private static boolean shouldCaptureUrl( } for (Map.Entry entry : allHeaders.entrySet()) { - if (normalizedAllowed.contains(entry.getKey().toLowerCase())) { + if (normalizedAllowed.contains(entry.getKey().toLowerCase(Locale.ROOT))) { capturedHeaders.put(entry.getKey(), entry.getValue()); } } From 896654c2972a9e3f461a73d1e3f9e7eedc9f1c22 Mon Sep 17 00:00:00 2001 From: Matthew Jay Williams <31186619+43jay@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:25:18 -0400 Subject: [PATCH 11/16] 2/2 Respect default Locale when lowercasing header strings Co-authored-by: Markus Hintersteiner --- .../java/io/sentry/util/network/NetworkDetailCaptureUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fb44423fcd2..761a3f5021b 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -131,7 +131,7 @@ private static boolean shouldCaptureUrl( Set normalizedAllowed = new HashSet<>(); for (String header : allowedHeaders) { if (header != null) { - normalizedAllowed.add(header.toLowerCase()); + normalizedAllowed.add(header.toLowerCase(Locale.ROOT)); } } From af55d736e0752dd9789f6674e99626ae7932ae13 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Tue, 25 Nov 2025 11:16:38 -0400 Subject: [PATCH 12/16] 3/2 Respect default Locale when lowercasing header strings --- .../java/io/sentry/util/network/NetworkDetailCaptureUtils.java | 1 + 1 file changed, 1 insertion(+) 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 761a3f5021b..887d36e0035 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -2,6 +2,7 @@ import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; import java.util.Set; import org.jetbrains.annotations.NotNull; From 1dd5ecfec9bdece9fa3c2392eb38b8bb3b2ab452 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Tue, 25 Nov 2025 11:33:36 -0400 Subject: [PATCH 13/16] Add code sample to CHANGELOG --- CHANGELOG.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9703b0761..2783b9a3952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,55 @@ ## Unreleased +### Features + +- Add option to capture additional OkHttp network request/response details in session replays ([#4919](https://github.com/getsentry/sentry-java/pull/4919)) + - Depends on `SentryOkHttpInterceptor` to intercept the request and extract request/response bodies + - To enable, add url regexes via the `io.sentry.session-replay.network-detail-allow-urls` metadata tag in AndroidManifest ([code sample](https://github.com/getsentry/sentry-java/blob/b03edbb1b0d8b871c62a09bc02cbd8a4e1f6fea1/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml#L196-L205)) + - Or you can manually specify SentryReplayOptions via `SentryAndroid#init`: +_(Make sure you disable the auto init via manifest meta-data: io.sentry.auto-init=false)_ + +
+ Kotlin + +```kotlin +SentryAndroid.init( + this, + options -> { + // options.dsn = "https://examplePublicKey@o0.ingest.sentry.io/0" + // options.sessionReplay.sessionSampleRate = 1.0 + // options.sessionReplay.onErrorSampleRate = 1.0 + // .. + + options.sessionReplay.networkDetailAllowUrls = listOf(".*") + options.sessionReplay.networkDetailDenyUrls = listOf(".*deny.*") + options.sessionReplay.networkRequestHeaders = listOf("Authorization", "X-Custom-Header", "X-Test-Request") + options.sessionReplay.networkResponseHeaders = listOf("X-Response-Time", "X-Cache-Status", "X-Test-Response") + }); +``` + +
+ +
+ Java + +```java +SentryAndroid.init( + this, + options -> { + options.getSessionReplay().setNetworkDetailAllowUrls(Arrays.asList(".*")); + options.getSessionReplay().setNetworkDetailDenyUrls(Arrays.asList(".*deny.*")); + options.getSessionReplay().setNetworkRequestHeaders( + Arrays.asList("Authorization", "X-Custom-Header", "X-Test-Request")); + options.getSessionReplay().setNetworkResponseHeaders( + Arrays.asList("X-Response-Time", "X-Cache-Status", "X-Test-Response")); + }); + +``` + +
+ + ### Improvements - Avoid forking `rootScopes` for Reactor if current thread has `NoOpScopes` ([#4793](https://github.com/getsentry/sentry-java/pull/4793)) @@ -21,10 +70,6 @@ ### Features -- Add option to capture additional network details for session replays (OkHttp) ([#4919](https://github.com/getsentry/sentry-java/pull/4919) - - Depends on `SentryOkHttpInterceptor` to intercept the request and extract request/response bodies. - - To enable, configure your sentry SDK using the "io.sentry.session-replay.network-*" options via [manifest](https://github.com/getsentry/sentry-java/blob/b03edbb1b0d8b871c62a09bc02cbd8a4e1f6fea1/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml#L196-L205) - - Or manually specify SentryReplayOptions via [SentryAndroid#init](https://github.com/getsentry/sentry-java/blob/c83e427e8baca17098f882f8b45fc7c5a80c1d8c/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java#L16-L28) - Implement OpenFeature Integration that tracks Feature Flag evaluations ([#4910](https://github.com/getsentry/sentry-java/pull/4910)) - To make use of it, add the `sentry-openfeature` dependency and register the the hook using: `openFeatureApiInstance.addHooks(new SentryOpenFeatureHook());` - Implement LaunchDarkly Integrations that track Feature Flag evaluations ([#4917](https://github.com/getsentry/sentry-java/pull/4917)) From b8ee888ca22f669630304b4fbfbaf8eea6e42d33 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Tue, 25 Nov 2025 15:53:58 -0400 Subject: [PATCH 14/16] Update API to accept List everywhere Previously some api was expecting String[], others were using List. Change everything to List for consistency --- .../android/core/ManifestMetadataReader.java | 12 ++-- sentry/api/sentry.api | 18 +++--- .../java/io/sentry/SentryReplayOptions.java | 52 ++++++++-------- .../io/sentry/rrweb/RRWebOptionsEvent.java | 6 +- .../network/NetworkDetailCaptureUtils.java | 23 +++---- .../RRWebOptionsEventSerializationTest.kt | 60 +++++++++---------- .../network/NetworkDetailCaptureUtilsTest.kt | 10 ++-- 7 files changed, 92 insertions(+), 89 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 59facd1a1e4..551b44cc510 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -507,7 +507,7 @@ static void applyMetadata( } // Network Details Configuration - if (options.getSessionReplay().getNetworkDetailAllowUrls().length == 0) { + if (options.getSessionReplay().getNetworkDetailAllowUrls().isEmpty()) { final @Nullable List allowUrls = readList(metadata, logger, REPLAYS_NETWORK_DETAIL_ALLOW_URLS); if (allowUrls != null && !allowUrls.isEmpty()) { @@ -521,12 +521,12 @@ static void applyMetadata( if (!filteredUrls.isEmpty()) { options .getSessionReplay() - .setNetworkDetailAllowUrls(filteredUrls.toArray(new String[0])); + .setNetworkDetailAllowUrls(filteredUrls); } } } - if (options.getSessionReplay().getNetworkDetailDenyUrls().length == 0) { + if (options.getSessionReplay().getNetworkDetailDenyUrls().isEmpty()) { final @Nullable List denyUrls = readList(metadata, logger, REPLAYS_NETWORK_DETAIL_DENY_URLS); if (denyUrls != null && !denyUrls.isEmpty()) { @@ -540,7 +540,7 @@ static void applyMetadata( if (!filteredUrls.isEmpty()) { options .getSessionReplay() - .setNetworkDetailDenyUrls(filteredUrls.toArray(new String[0])); + .setNetworkDetailDenyUrls(filteredUrls); } } } @@ -554,7 +554,7 @@ static void applyMetadata( REPLAYS_NETWORK_CAPTURE_BODIES, options.getSessionReplay().isNetworkCaptureBodies() /* defaultValue */)); - if (options.getSessionReplay().getNetworkRequestHeaders().length + if (options.getSessionReplay().getNetworkRequestHeaders().size() == SentryReplayOptions.getNetworkDetailsDefaultHeaders().size()) { // Only has defaults final @Nullable List requestHeaders = readList(metadata, logger, REPLAYS_NETWORK_REQUEST_HEADERS); @@ -572,7 +572,7 @@ static void applyMetadata( } } - if (options.getSessionReplay().getNetworkResponseHeaders().length + if (options.getSessionReplay().getNetworkResponseHeaders().size() == SentryReplayOptions.getNetworkDetailsDefaultHeaders().size()) { // Only has defaults final @Nullable List responseHeaders = readList(metadata, logger, REPLAYS_NETWORK_RESPONSE_HEADERS); diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 5db3028983d..dedb3b0e80f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3801,11 +3801,11 @@ public final class io/sentry/SentryReplayOptions { public fun getFrameRate ()I public fun getMaskViewClasses ()Ljava/util/Set; public fun getMaskViewContainerClass ()Ljava/lang/String; - public fun getNetworkDetailAllowUrls ()[Ljava/lang/String; - public fun getNetworkDetailDenyUrls ()[Ljava/lang/String; + public fun getNetworkDetailAllowUrls ()Ljava/util/List; + public fun getNetworkDetailDenyUrls ()Ljava/util/List; public static fun getNetworkDetailsDefaultHeaders ()Ljava/util/List; - public fun getNetworkRequestHeaders ()[Ljava/lang/String; - public fun getNetworkResponseHeaders ()[Ljava/lang/String; + public fun getNetworkRequestHeaders ()Ljava/util/List; + public fun getNetworkResponseHeaders ()Ljava/util/List; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; public fun getScreenshotStrategy ()Lio/sentry/ScreenshotStrategyType; @@ -3825,8 +3825,8 @@ public final class io/sentry/SentryReplayOptions { public fun setMaskAllText (Z)V public fun setMaskViewContainerClass (Ljava/lang/String;)V public fun setNetworkCaptureBodies (Z)V - public fun setNetworkDetailAllowUrls ([Ljava/lang/String;)V - public fun setNetworkDetailDenyUrls ([Ljava/lang/String;)V + public fun setNetworkDetailAllowUrls (Ljava/util/List;)V + public fun setNetworkDetailDenyUrls (Ljava/util/List;)V public fun setNetworkRequestHeaders (Ljava/util/List;)V public fun setNetworkResponseHeaders (Ljava/util/List;)V public fun setOnErrorSampleRate (Ljava/lang/Double;)V @@ -7496,9 +7496,9 @@ public final class io/sentry/util/network/NetworkBodyParser { } public final class io/sentry/util/network/NetworkDetailCaptureUtils { - public static fun createRequest (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;[Ljava/lang/String;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; - public static fun createResponse (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;[Ljava/lang/String;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; - public static fun initializeForUrl (Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/String;)Lio/sentry/util/network/NetworkRequestData; + public static fun createRequest (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; + public static fun createResponse (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; + public static fun initializeForUrl (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lio/sentry/util/network/NetworkRequestData; } public abstract interface class io/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor { diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index bb1538569b8..c6bb35701a6 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -2,6 +2,7 @@ import io.sentry.protocol.SdkVersion; import io.sentry.util.SampleRateUtils; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashSet; @@ -165,18 +166,18 @@ public enum SentryReplayQuality { * Capture request and response details for XHR and fetch requests that match the given URLs. * Default is empty (network details not collected). */ - private @NotNull String[] networkDetailAllowUrls = new String[0]; + private @NotNull List networkDetailAllowUrls = Collections.emptyList(); /** * Do not capture request and response details for these URLs. Takes precedence over * networkDetailAllowUrls. Default is empty. */ - private @NotNull String[] networkDetailDenyUrls = new String[0]; + private @NotNull List networkDetailDenyUrls = Collections.emptyList(); /** * Decide whether to capture request and response bodies for URLs defined in * networkDetailAllowUrls. Default is true, but capturing bodies requires at least one url - * specified via {@link #setNetworkDetailAllowUrls(String[])}. + * specified via {@link #setNetworkDetailAllowUrls(List)}. */ private boolean networkCaptureBodies = true; @@ -198,13 +199,13 @@ public enum SentryReplayQuality { * Additional request headers to capture for URLs defined in networkDetailAllowUrls. The default * headers (Content-Type, Content-Length, Accept) are always included in addition to these. */ - private @NotNull String[] networkRequestHeaders = DEFAULT_HEADERS.toArray(new String[0]); + private @NotNull List networkRequestHeaders = DEFAULT_HEADERS; /** * Additional response headers to capture for URLs defined in networkDetailAllowUrls. The default * headers (Content-Type, Content-Length, Accept) are always included in addition to these. */ - private @NotNull String[] networkResponseHeaders = DEFAULT_HEADERS.toArray(new String[0]); + private @NotNull List networkResponseHeaders = DEFAULT_HEADERS; public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { @@ -428,40 +429,40 @@ public void setScreenshotStrategy(final @NotNull ScreenshotStrategyType screensh } /** - * Gets the array of URLs for which network request and response details should be captured. + * Gets the list of URLs for which network request and response details should be captured. * - * @return the network detail allow URLs array + * @return the network detail allow URLs list */ - public @NotNull String[] getNetworkDetailAllowUrls() { + public @NotNull List getNetworkDetailAllowUrls() { return networkDetailAllowUrls; } /** - * Sets the array of URLs for which network request and response details should be captured. + * Sets the list of URLs for which network request and response details should be captured. * - * @param networkDetailAllowUrls the network detail allow URLs array + * @param networkDetailAllowUrls the network detail allow URLs list */ - public void setNetworkDetailAllowUrls(final @NotNull String[] networkDetailAllowUrls) { - this.networkDetailAllowUrls = networkDetailAllowUrls; + public void setNetworkDetailAllowUrls(final @NotNull List networkDetailAllowUrls) { + this.networkDetailAllowUrls = Collections.unmodifiableList(new ArrayList<>(networkDetailAllowUrls)); } /** - * Gets the array of URLs for which network request and response details should NOT be captured. + * Gets the list of URLs for which network request and response details should NOT be captured. * - * @return the network detail deny URLs array + * @return the network detail deny URLs list */ - public @NotNull String[] getNetworkDetailDenyUrls() { + public @NotNull List getNetworkDetailDenyUrls() { return networkDetailDenyUrls; } /** - * Sets the array of URLs for which network request and response details should NOT be captured. + * Sets the list of URLs for which network request and response details should NOT be captured. * Takes precedence over networkDetailAllowUrls. * - * @param networkDetailDenyUrls the network detail deny URLs array + * @param networkDetailDenyUrls the network detail deny URLs list */ - public void setNetworkDetailDenyUrls(final @NotNull String[] networkDetailDenyUrls) { - this.networkDetailDenyUrls = networkDetailDenyUrls; + public void setNetworkDetailDenyUrls(final @NotNull List networkDetailDenyUrls) { + this.networkDetailDenyUrls = Collections.unmodifiableList(new ArrayList<>(networkDetailDenyUrls)); } /** @@ -486,9 +487,9 @@ public void setNetworkCaptureBodies(final boolean networkCaptureBodies) { * Gets all request headers to capture for URLs defined in networkDetailAllowUrls. This includes * both the default headers (Content-Type, Content-Length, Accept) and any additional headers. * - * @return the complete network request headers array + * @return an unmodifiable list of the request headers to extract */ - public @NotNull String[] getNetworkRequestHeaders() { + public @NotNull List getNetworkRequestHeaders() { return networkRequestHeaders; } @@ -506,9 +507,9 @@ public void setNetworkRequestHeaders(final @NotNull List networkRequestH * Gets all response headers to capture for URLs defined in networkDetailAllowUrls. This includes * both the default headers (Content-Type, Content-Length, Accept) and any additional headers. * - * @return the complete network response headers array + * @return an unmodifiable list of the response headers to extract */ - public @NotNull String[] getNetworkResponseHeaders() { + public @NotNull List getNetworkResponseHeaders() { return networkResponseHeaders; } @@ -527,12 +528,13 @@ public void setNetworkResponseHeaders(final @NotNull List networkRespons * * @param defaultHeaders the default headers that are always included * @param additionalHeaders additional headers to merge + * @return an unmodifiable list of merged headers */ - private static @NotNull String[] mergeHeaders( + private static @NotNull List mergeHeaders( final @NotNull List defaultHeaders, final @NotNull List additionalHeaders) { final Set merged = new LinkedHashSet<>(); merged.addAll(defaultHeaders); merged.addAll(additionalHeaders); - return merged.toArray(new String[0]); + return Collections.unmodifiableList(new ArrayList<>(merged)); } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java index d61dd320e0f..5305e59a321 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -60,17 +60,17 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) { : "canvas"; optionsPayload.put("screenshotStrategy", screenshotStrategy); optionsPayload.put( - "networkDetailHasUrls", replayOptions.getNetworkDetailAllowUrls().length > 0); + "networkDetailHasUrls", !replayOptions.getNetworkDetailAllowUrls().isEmpty()); // Add network detail configuration options - if (replayOptions.getNetworkDetailAllowUrls().length > 0) { + if (!replayOptions.getNetworkDetailAllowUrls().isEmpty()) { optionsPayload.put("networkDetailAllowUrls", replayOptions.getNetworkDetailAllowUrls()); optionsPayload.put("networkRequestHeaders", replayOptions.getNetworkRequestHeaders()); optionsPayload.put("networkResponseHeaders", replayOptions.getNetworkResponseHeaders()); optionsPayload.put("networkCaptureBodies", replayOptions.isNetworkCaptureBodies()); - if (replayOptions.getNetworkDetailDenyUrls().length > 0) { + if (!replayOptions.getNetworkDetailDenyUrls().isEmpty()) { optionsPayload.put("networkDetailDenyUrls", replayOptions.getNetworkDetailDenyUrls()); } } 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 887d36e0035..e0438c375b1 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -2,6 +2,7 @@ import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -32,8 +33,8 @@ public interface NetworkHeaderExtractor { public static @Nullable NetworkRequestData initializeForUrl( @NotNull final String url, @Nullable final String method, - @Nullable final String[] networkDetailAllowUrls, - @Nullable final String[] networkDetailDenyUrls) { + @Nullable final List networkDetailAllowUrls, + @Nullable final List networkDetailDenyUrls) { if (!shouldCaptureUrl(url, networkDetailAllowUrls, networkDetailDenyUrls)) { return null; @@ -51,7 +52,7 @@ public interface NetworkHeaderExtractor { @Nullable final Long bodySize, final boolean networkCaptureBodies, @NotNull final NetworkBodyExtractor bodyExtractor, - @NotNull final String[] networkRequestHeaders, + @NotNull final List networkRequestHeaders, @NotNull final NetworkHeaderExtractor headerExtractor) { return createRequestOrResponseInternal( @@ -68,7 +69,7 @@ public interface NetworkHeaderExtractor { @Nullable final Long bodySize, final boolean networkCaptureBodies, @NotNull final NetworkBodyExtractor bodyExtractor, - @NotNull final String[] networkResponseHeaders, + @NotNull final List networkResponseHeaders, @NotNull final NetworkHeaderExtractor headerExtractor) { return createRequestOrResponseInternal( @@ -85,15 +86,15 @@ public interface NetworkHeaderExtractor { * href="https://docs.sentry.io/platforms/javascript/session-replay/configuration/">docs.sentry.io * * @param url The URL to check - * @param networkDetailAllowUrls Array of regex patterns that allow capture - * @param networkDetailDenyUrls Array of regex patterns to explicitly deny capture. Takes + * @param networkDetailAllowUrls List of regex patterns that allow capture + * @param networkDetailDenyUrls List of regex patterns to explicitly deny capture. Takes * precedence over networkDetailAllowUrls. * @return true if the URL should be captured, false otherwise */ private static boolean shouldCaptureUrl( @NotNull final String url, - @Nullable final String[] networkDetailAllowUrls, - @Nullable final String[] networkDetailDenyUrls) { + @Nullable final List networkDetailAllowUrls, + @Nullable final List networkDetailDenyUrls) { // If there are deny patterns and URL matches any, don't capture. if (networkDetailDenyUrls != null) { @@ -121,10 +122,10 @@ private static boolean shouldCaptureUrl( @VisibleForTesting static @NotNull Map getCaptureHeaders( - @Nullable final Map allHeaders, @NotNull final String[] allowedHeaders) { + @Nullable final Map allHeaders, @NotNull final List allowedHeaders) { final Map capturedHeaders = new LinkedHashMap<>(); - if (allHeaders == null || allowedHeaders.length == 0) { + if (allHeaders == null) { return capturedHeaders; } @@ -150,7 +151,7 @@ private static boolean shouldCaptureUrl( @Nullable final Long bodySize, final boolean networkCaptureBodies, @NotNull final NetworkBodyExtractor bodyExtractor, - @NotNull final String[] allowedHeaders, + @NotNull final List allowedHeaders, @NotNull final NetworkHeaderExtractor headerExtractor) { NetworkBody body = null; diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt index bcfed3ede6b..32298eee185 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt @@ -2,10 +2,10 @@ package io.sentry.rrweb import io.sentry.ILogger import io.sentry.SentryOptions +import io.sentry.SentryReplayOptions import io.sentry.SentryReplayOptions.SentryReplayQuality.LOW import io.sentry.protocol.SdkVersion import io.sentry.protocol.SerializationUtils -import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -56,10 +56,10 @@ class RRWebOptionsEventSerializationTest { fun `network detail fields are not included when networkDetailAllowUrls is empty`() { val options = SentryOptions().apply { - sessionReplay.setNetworkDetailAllowUrls(emptyArray()) + sessionReplay.setNetworkDetailAllowUrls(emptyList()) // Any config is ignored when no allowUrls are specified. - sessionReplay.setNetworkDetailDenyUrls(arrayOf("https://internal.example.com/*")) + sessionReplay.setNetworkDetailDenyUrls(listOf("https://internal.example.com/*")) sessionReplay.setNetworkRequestHeaders(listOf("Authorization", "X-Custom")) sessionReplay.setNetworkResponseHeaders(listOf("X-RateLimit", "Content-Type")) } @@ -78,7 +78,7 @@ class RRWebOptionsEventSerializationTest { fun `networkDetailAllowUrls and headers are included when networkDetailAllowUrls is configured`() { val options = SentryOptions().apply { - sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + sessionReplay.setNetworkDetailAllowUrls(listOf("https://api.example.com/*")) sessionReplay.setNetworkRequestHeaders(listOf("Authorization", "X-Custom")) sessionReplay.setNetworkResponseHeaders(listOf("X-RateLimit", "Content-Type")) } @@ -89,17 +89,17 @@ class RRWebOptionsEventSerializationTest { assertTrue(payload.containsKey("networkRequestHeaders")) assertTrue(payload.containsKey("networkResponseHeaders")) assertEquals(true, payload["networkDetailHasUrls"]) - assertContentEquals( - arrayOf("https://api.example.com/*"), - payload["networkDetailAllowUrls"] as Array, + assertEquals( + listOf("https://api.example.com/*"), + (payload["networkDetailAllowUrls"] as List), ) - assertContentEquals( - arrayOf("Content-Type", "Content-Length", "Accept", "Authorization", "X-Custom"), - payload["networkRequestHeaders"] as Array, + assertEquals( + (SentryReplayOptions.getNetworkDetailsDefaultHeaders() + listOf("Authorization", "X-Custom")).toSet(), + (payload["networkRequestHeaders"] as List).toSet(), ) - assertContentEquals( - arrayOf("Content-Type", "Content-Length", "Accept", "X-RateLimit"), - payload["networkResponseHeaders"] as Array, + assertEquals( + (SentryReplayOptions.getNetworkDetailsDefaultHeaders() + listOf("X-RateLimit")).toSet(), + (payload["networkResponseHeaders"] as List).toSet(), ) } @@ -107,21 +107,21 @@ class RRWebOptionsEventSerializationTest { fun `networkDetailDenyUrls are included when networkDetailAllowUrls is configured`() { val options = SentryOptions().apply { - sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) - sessionReplay.setNetworkDetailDenyUrls(arrayOf("https://internal.example.com/*")) + sessionReplay.setNetworkDetailAllowUrls(listOf("https://api.example.com/*")) + sessionReplay.setNetworkDetailDenyUrls(listOf("https://internal.example.com/*")) } val event = RRWebOptionsEvent(options) val payload = event.optionsPayload assertTrue(payload.containsKey("networkDetailAllowUrls")) assertTrue(payload.containsKey("networkDetailDenyUrls")) - assertContentEquals( - arrayOf("https://api.example.com/*"), - payload["networkDetailAllowUrls"] as Array, + assertEquals( + listOf("https://api.example.com/*"), + (payload["networkDetailAllowUrls"] as List), ) - assertContentEquals( - arrayOf("https://internal.example.com/*"), - payload["networkDetailDenyUrls"] as Array, + assertEquals( + listOf("https://internal.example.com/*"), + (payload["networkDetailDenyUrls"] as List), ) } @@ -129,7 +129,7 @@ class RRWebOptionsEventSerializationTest { fun `networkCaptureBodies is included when networkDetailAllowUrls is configured`() { val options = SentryOptions().apply { - sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + sessionReplay.setNetworkDetailAllowUrls(listOf("https://api.example.com/*")) sessionReplay.setNetworkCaptureBodies(false) } val event = RRWebOptionsEvent(options) @@ -143,7 +143,7 @@ class RRWebOptionsEventSerializationTest { fun `default networkCaptureBodies is included when networkDetailAllowUrls is configured`() { val options = SentryOptions().apply { - sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + sessionReplay.setNetworkDetailAllowUrls(listOf("https://api.example.com/*")) } val event = RRWebOptionsEvent(options) @@ -156,7 +156,7 @@ class RRWebOptionsEventSerializationTest { fun `default network request and response headers are included when networkDetailAllowUrls is configured but no custom headers set`() { val options = SentryOptions().apply { - sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + sessionReplay.setNetworkDetailAllowUrls(listOf("https://api.example.com/*")) // No custom headers set, should use defaults only } val event = RRWebOptionsEvent(options) @@ -164,13 +164,13 @@ class RRWebOptionsEventSerializationTest { val payload = event.optionsPayload assertTrue(payload.containsKey("networkRequestHeaders")) assertTrue(payload.containsKey("networkResponseHeaders")) - assertContentEquals( - arrayOf("Content-Type", "Content-Length", "Accept"), - payload["networkRequestHeaders"] as Array, + assertEquals( + SentryReplayOptions.getNetworkDetailsDefaultHeaders().toSet(), + (payload["networkRequestHeaders"] as List).toSet(), ) - assertContentEquals( - arrayOf("Content-Type", "Content-Length", "Accept"), - payload["networkResponseHeaders"] as Array, + assertEquals( + SentryReplayOptions.getNetworkDetailsDefaultHeaders().toSet(), + (payload["networkResponseHeaders"] as List).toSet(), ) } } 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 c3b3faef61f..cf4ec4828ff 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt @@ -19,7 +19,7 @@ class NetworkDetailCaptureUtilsTest { } // Test: allowedHeaders with different casing - val allowedHeaders = arrayOf("content-type", "AUTHORIZATION", "x-custom-header", "ACCEPT") + val allowedHeaders = listOf("content-type", "AUTHORIZATION", "x-custom-header", "ACCEPT") val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) @@ -41,7 +41,7 @@ class NetworkDetailCaptureUtilsTest { @Test fun `getCaptureHeaders should handle null allHeaders`() { - val allowedHeaders = arrayOf("content-type") + val allowedHeaders = listOf("content-type") val result = NetworkDetailCaptureUtils.getCaptureHeaders(null, allowedHeaders) @@ -51,7 +51,7 @@ class NetworkDetailCaptureUtilsTest { @Test fun `getCaptureHeaders should handle empty allowedHeaders`() { val allHeaders = mapOf("Content-Type" to "application/json") - val allowedHeaders = arrayOf() + val allowedHeaders = emptyList() val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) @@ -67,7 +67,7 @@ class NetworkDetailCaptureUtilsTest { "X-Unwanted-Header" to "should-not-appear", ) - val allowedHeaders = arrayOf("content-type", "authorization") + val allowedHeaders = listOf("content-type", "authorization") val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) @@ -89,7 +89,7 @@ class NetworkDetailCaptureUtilsTest { ) // allowedHeaders contains null elements which should be ignored - val allowedHeaders = arrayOf(null, "content-type", null, "authorization", null) + val allowedHeaders = listOf(null, "content-type", null, "authorization", null) val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) From d3114de375467423afdc6eab556a94edb178010b Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 25 Nov 2025 19:56:11 +0000 Subject: [PATCH 15/16] Format code --- .../io/sentry/android/core/ManifestMetadataReader.java | 8 ++------ sentry/src/main/java/io/sentry/SentryReplayOptions.java | 6 ++++-- .../io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt | 3 ++- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 551b44cc510..a6392d4895e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -519,9 +519,7 @@ static void applyMetadata( } } if (!filteredUrls.isEmpty()) { - options - .getSessionReplay() - .setNetworkDetailAllowUrls(filteredUrls); + options.getSessionReplay().setNetworkDetailAllowUrls(filteredUrls); } } } @@ -538,9 +536,7 @@ static void applyMetadata( } } if (!filteredUrls.isEmpty()) { - options - .getSessionReplay() - .setNetworkDetailDenyUrls(filteredUrls); + options.getSessionReplay().setNetworkDetailDenyUrls(filteredUrls); } } } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index c6bb35701a6..367b150c921 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -443,7 +443,8 @@ public void setScreenshotStrategy(final @NotNull ScreenshotStrategyType screensh * @param networkDetailAllowUrls the network detail allow URLs list */ public void setNetworkDetailAllowUrls(final @NotNull List networkDetailAllowUrls) { - this.networkDetailAllowUrls = Collections.unmodifiableList(new ArrayList<>(networkDetailAllowUrls)); + this.networkDetailAllowUrls = + Collections.unmodifiableList(new ArrayList<>(networkDetailAllowUrls)); } /** @@ -462,7 +463,8 @@ public void setNetworkDetailAllowUrls(final @NotNull List networkDetailA * @param networkDetailDenyUrls the network detail deny URLs list */ public void setNetworkDetailDenyUrls(final @NotNull List networkDetailDenyUrls) { - this.networkDetailDenyUrls = Collections.unmodifiableList(new ArrayList<>(networkDetailDenyUrls)); + this.networkDetailDenyUrls = + Collections.unmodifiableList(new ArrayList<>(networkDetailDenyUrls)); } /** diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt index 32298eee185..32dbd9a7d47 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt @@ -94,7 +94,8 @@ class RRWebOptionsEventSerializationTest { (payload["networkDetailAllowUrls"] as List), ) assertEquals( - (SentryReplayOptions.getNetworkDetailsDefaultHeaders() + listOf("Authorization", "X-Custom")).toSet(), + (SentryReplayOptions.getNetworkDetailsDefaultHeaders() + listOf("Authorization", "X-Custom")) + .toSet(), (payload["networkRequestHeaders"] as List).toSet(), ) assertEquals( From a151c5b53e81e9edc73634a548bcf7430f5143ca Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Tue, 25 Nov 2025 15:53:58 -0400 Subject: [PATCH 16/16] rebase --- sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt index 33fa408dac3..cf96bd5d7de 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -67,7 +67,7 @@ class SentryReplayOptionsTest { options.networkRequestHeaders.size, ) - val headers = options.networkRequestHeaders.toList() + val headers = options.networkRequestHeaders SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> assertEquals(true, headers.contains(defaultHeader)) } @@ -81,7 +81,7 @@ class SentryReplayOptionsTest { options.networkResponseHeaders.size, ) - val headers = options.networkResponseHeaders.toList() + val headers = options.networkResponseHeaders SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> assertEquals(true, headers.contains(defaultHeader)) } @@ -99,7 +99,7 @@ class SentryReplayOptionsTest { options.networkRequestHeaders.size, ) - val headers = options.networkRequestHeaders.toList() + val headers = options.networkRequestHeaders SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> assertTrue(headers.contains(defaultHeader)) } @@ -119,7 +119,7 @@ class SentryReplayOptionsTest { options.networkResponseHeaders.size, ) - val headers = options.networkResponseHeaders.toList() + val headers = options.networkResponseHeaders SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> assertTrue(headers.contains(defaultHeader)) }