diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48568805a36..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))
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..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
@@ -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()) {
@@ -519,14 +519,12 @@ static void applyMetadata(
}
}
if (!filteredUrls.isEmpty()) {
- options
- .getSessionReplay()
- .setNetworkDetailAllowUrls(filteredUrls.toArray(new String[0]));
+ options.getSessionReplay().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()) {
@@ -538,9 +536,7 @@ static void applyMetadata(
}
}
if (!filteredUrls.isEmpty()) {
- options
- .getSessionReplay()
- .setNetworkDetailDenyUrls(filteredUrls.toArray(new String[0]));
+ options.getSessionReplay().setNetworkDetailDenyUrls(filteredUrls);
}
}
}
@@ -554,7 +550,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 +568,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-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..af7b25a782e 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
@@ -209,6 +210,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
@@ -260,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 semicolon separator
+ headers[name] = "$existingValue; $value"
+ } else {
+ headers[name] = value
+ }
}
return headers
}
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..5570e37787b 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")
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..6e8b8548731 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"])
+ }
}
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();
}
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..367b150c921 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,42 @@ 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 +489,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 +509,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 +530,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 3ed8e1e1674..e0438c375b1 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,14 @@
package io.sentry.util.network;
-import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
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
@@ -28,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;
@@ -47,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(
@@ -64,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(
@@ -81,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) {
@@ -115,19 +120,26 @@ private static boolean shouldCaptureUrl(
return false;
}
- private static @NotNull Map getCaptureHeaders(
- @Nullable final Map allHeaders, @NotNull final String[] allowedHeaders) {
-
- Map capturedHeaders = new HashMap<>();
+ @VisibleForTesting
+ static @NotNull Map getCaptureHeaders(
+ @Nullable final Map allHeaders, @NotNull final List allowedHeaders) {
+ final Map capturedHeaders = new LinkedHashMap<>();
if (allHeaders == null) {
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);
+ if (header != null) {
+ normalizedAllowed.add(header.toLowerCase(Locale.ROOT));
+ }
+ }
+
+ for (Map.Entry entry : allHeaders.entrySet()) {
+ if (normalizedAllowed.contains(entry.getKey().toLowerCase(Locale.ROOT))) {
+ capturedHeaders.put(entry.getKey(), entry.getValue());
}
}
@@ -139,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/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))
}
diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt
index bcfed3ede6b..32dbd9a7d47 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,18 @@ 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 +108,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 +130,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 +144,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 +157,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 +165,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
new file mode 100644
index 00000000000..cf4ec4828ff
--- /dev/null
+++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt
@@ -0,0 +1,104 @@
+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 = listOf("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 = listOf("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 = emptyList()
+
+ 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 = listOf("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"))
+ }
+
+ @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 = listOf(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"))
+ }
+}