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")) + } +}