From 61cb6e4a8f236f0a9e9010ecf3d21b2d5bb3b736 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Tue, 11 Nov 2025 17:06:59 -0400 Subject: [PATCH 01/11] Add network details to SentryReplayOptions API ./gradlew apiDump --- sentry/api/sentry.api | 11 ++ .../java/io/sentry/SentryReplayOptions.java | 158 ++++++++++++++++++ .../java/io/sentry/SentryReplayOptionsTest.kt | 72 ++++++++ 3 files changed, 241 insertions(+) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fb241c9b4ef..a767e849289 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3792,6 +3792,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 static fun getNetworkDetailsDefaultHeaders ()[Ljava/lang/String; + public fun getNetworkRequestHeaders ()[Ljava/lang/String; + public fun getNetworkResponseHeaders ()[Ljava/lang/String; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; public fun getScreenshotStrategy ()Lio/sentry/ScreenshotStrategyType; @@ -3802,6 +3807,7 @@ public final class io/sentry/SentryReplayOptions { public fun getUnmaskViewClasses ()Ljava/util/Set; public fun getUnmaskViewContainerClass ()Ljava/lang/String; public fun isDebug ()Z + public fun isNetworkCaptureBodies ()Z public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z public fun isTrackConfiguration ()Z @@ -3809,6 +3815,11 @@ public final class io/sentry/SentryReplayOptions { public fun setMaskAllImages (Z)V 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 setNetworkRequestHeaders (Ljava/util/List;)V + public fun setNetworkResponseHeaders (Ljava/util/List;)V public fun setOnErrorSampleRate (Ljava/lang/Double;)V public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V public fun setScreenshotStrategy (Lio/sentry/ScreenshotStrategyType;)V diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 2213f3ee960..3f13e131a62 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -2,6 +2,10 @@ import io.sentry.protocol.SdkVersion; import io.sentry.util.SampleRateUtils; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Locale; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; @@ -157,6 +161,50 @@ public enum SentryReplayQuality { @ApiStatus.Experimental private @NotNull ScreenshotStrategyType screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY; + /** + * 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]; + + /** + * Do not capture request and response details for these URLs. Takes precedence over + * networkDetailAllowUrls. Default is empty. + */ + private @NotNull String[] networkDetailDenyUrls = new String[0]; + + /** + * 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[])}. + */ + private boolean networkCaptureBodies = true; + + /** Default headers that are always captured for URLs defined in networkDetailAllowUrls. */ + private static final @NotNull String[] DEFAULT_HEADERS = + new String[] {"Content-Type", "Content-Length", "Accept"}; + + /** + * Gets the default headers that are always captured for URLs defined in networkDetailAllowUrls. + * Returns a defensive copy to prevent modification. + */ + @ApiStatus.Internal + public static @NotNull String[] getNetworkDetailsDefaultHeaders() { + return DEFAULT_HEADERS.clone(); + } + + /** + * 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 List networkRequestHeaders = new ArrayList<>(); + + /** + * 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 List networkResponseHeaders = new ArrayList<>(); + public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { setMaskAllText(true); @@ -377,4 +425,114 @@ public void setDebug(final boolean debug) { public void setScreenshotStrategy(final @NotNull ScreenshotStrategyType screenshotStrategy) { this.screenshotStrategy = screenshotStrategy; } + + /** + * Gets the array of URLs for which network request and response details should be captured. + * + * @return the network detail allow URLs array + */ + public @NotNull String[] getNetworkDetailAllowUrls() { + return networkDetailAllowUrls; + } + + /** + * Sets the array of URLs for which network request and response details should be captured. + * + * @param networkDetailAllowUrls the network detail allow URLs array + */ + public void setNetworkDetailAllowUrls(final @NotNull String[] networkDetailAllowUrls) { + this.networkDetailAllowUrls = networkDetailAllowUrls; + } + + /** + * Gets the array of URLs for which network request and response details should NOT be captured. + * + * @return the network detail deny URLs array + */ + public @NotNull String[] getNetworkDetailDenyUrls() { + return networkDetailDenyUrls; + } + + /** + * Sets the array 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 + */ + public void setNetworkDetailDenyUrls(final @NotNull String[] networkDetailDenyUrls) { + this.networkDetailDenyUrls = networkDetailDenyUrls; + } + + /** + * Gets whether to capture request and response bodies for URLs defined in networkDetailAllowUrls. + * + * @return true if network capture bodies is enabled, false otherwise + */ + public boolean isNetworkCaptureBodies() { + return networkCaptureBodies; + } + + /** + * Sets whether to capture request and response bodies for URLs defined in networkDetailAllowUrls. + * + * @param networkCaptureBodies true to enable network capture bodies, false otherwise + */ + public void setNetworkCaptureBodies(final boolean networkCaptureBodies) { + this.networkCaptureBodies = 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 + */ + public @NotNull String[] getNetworkRequestHeaders() { + return mergeHeaders(DEFAULT_HEADERS, networkRequestHeaders); + } + + /** + * Sets additional request headers to capture for URLs defined in networkDetailAllowUrls. The + * default headers (Content-Type, Content-Length, Accept) are always included automatically. + * + * @param networkRequestHeaders additional network request headers list + */ + public void setNetworkRequestHeaders(final @NotNull List networkRequestHeaders) { + this.networkRequestHeaders = new ArrayList<>(networkRequestHeaders); + } + + /** + * Gets all response headers to capture for URLs defined in networkDetailAllowUrls. This includes + * both the default headers (Content-Type, Content-Length, Content-Encoding) and any additional + * headers. + * + * @return the complete network response headers array + */ + public @NotNull String[] getNetworkResponseHeaders() { + return mergeHeaders(DEFAULT_HEADERS, networkResponseHeaders); + } + + /** + * Sets additional response headers to capture for URLs defined in networkDetailAllowUrls. The + * default headers (Content-Type, Content-Length, Accept) are always included automatically. + * + * @param networkResponseHeaders the additional network response headers list + */ + public void setNetworkResponseHeaders(final @NotNull List networkResponseHeaders) { + this.networkResponseHeaders = new ArrayList<>(networkResponseHeaders); + } + + /** + * Merges default headers with additional headers, removing duplicates while preserving order. + * + * @param defaultHeaders the default headers that are always included + * @param additionalHeaders additional headers to merge + */ + private static @NotNull String[] mergeHeaders( + final @NotNull String[] defaultHeaders, final @NotNull List additionalHeaders) { + final Set merged = new LinkedHashSet<>(); + merged.addAll(Arrays.asList(defaultHeaders)); + merged.addAll(additionalHeaders); + return merged.toArray(new String[0]); + } } diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt index a1eb0245955..33fa408dac3 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -2,6 +2,7 @@ package io.sentry import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class SentryReplayOptionsTest { @Test @@ -54,4 +55,75 @@ class SentryReplayOptionsTest { options.screenshotStrategy = ScreenshotStrategyType.PIXEL_COPY assertEquals(ScreenshotStrategyType.PIXEL_COPY, options.getScreenshotStrategy()) } + + // Network Details Options + // https://docs.sentry.io/platforms/javascript/session-replay/configuration/#network-details + + @Test + fun `getNetworkRequestHeaders returns default headers by default`() { + val options = SentryReplayOptions(false, null) + assertEquals( + SentryReplayOptions.getNetworkDetailsDefaultHeaders().size, + options.networkRequestHeaders.size, + ) + + val headers = options.networkRequestHeaders.toList() + SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> + assertEquals(true, headers.contains(defaultHeader)) + } + } + + @Test + fun `getNetworkResponseHeaders returns default headers by default`() { + val options = SentryReplayOptions(false, null) + assertEquals( + SentryReplayOptions.getNetworkDetailsDefaultHeaders().size, + options.networkResponseHeaders.size, + ) + + val headers = options.networkResponseHeaders.toList() + SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> + assertEquals(true, headers.contains(defaultHeader)) + } + } + + @Test + fun `setNetworkRequestHeaders adds to default headers`() { + val options = SentryReplayOptions(false, null) + val additionalHeaders = listOf("X-Custom-Header", "X-Another-Header") + + options.setNetworkRequestHeaders(additionalHeaders) + + assertEquals( + SentryReplayOptions.getNetworkDetailsDefaultHeaders().size + additionalHeaders.size, + options.networkRequestHeaders.size, + ) + + val headers = options.networkRequestHeaders.toList() + SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> + assertTrue(headers.contains(defaultHeader)) + } + assertTrue(headers.contains("X-Custom-Header")) + assertTrue(headers.contains("X-Another-Header")) + } + + @Test + fun `setNetworkResponseHeaders adds to default headers`() { + val options = SentryReplayOptions(false, null) + val additionalHeaders = listOf("X-Response-Header", "X-Debug-Header") + + options.setNetworkResponseHeaders(additionalHeaders) + + assertEquals( + SentryReplayOptions.getNetworkDetailsDefaultHeaders().size + additionalHeaders.size, + options.networkResponseHeaders.size, + ) + + val headers = options.networkResponseHeaders.toList() + SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> + assertTrue(headers.contains(defaultHeader)) + } + assertTrue(headers.contains("X-Response-Header")) + assertTrue(headers.contains("X-Debug-Header")) + } } From e60ac8068fcd1456eda24672b81752088609ba6a Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Wed, 12 Nov 2025 15:18:08 -0400 Subject: [PATCH 02/11] Extract NetworkDetails options from manifest --- .../android/core/ManifestMetadataReader.java | 102 ++++++++ .../core/ManifestMetadataReaderTest.kt | 246 ++++++++++++++++++ 2 files changed, 348 insertions(+) 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 a71ec1cb764..a8d15fe1702 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 @@ -10,8 +10,10 @@ import io.sentry.SentryFeedbackOptions; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; +import io.sentry.SentryReplayOptions; import io.sentry.protocol.SdkVersion; import io.sentry.util.Objects; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -114,6 +116,21 @@ final class ManifestMetadataReader { static final String REPLAYS_DEBUG = "io.sentry.session-replay.debug"; static final String REPLAYS_SCREENSHOT_STRATEGY = "io.sentry.session-replay.screenshot-strategy"; + static final String REPLAYS_NETWORK_DETAIL_ALLOW_URLS = + "io.sentry.session-replay.network-detail-allow-urls"; + + static final String REPLAYS_NETWORK_DETAIL_DENY_URLS = + "io.sentry.session-replay.network-detail-deny-urls"; + + static final String REPLAYS_NETWORK_CAPTURE_BODIES = + "io.sentry.session-replay.network-capture-bodies"; + + static final String REPLAYS_NETWORK_REQUEST_HEADERS = + "io.sentry.session-replay.network-request-headers"; + + static final String REPLAYS_NETWORK_RESPONSE_HEADERS = + "io.sentry.session-replay.network-response-headers"; + static final String FORCE_INIT = "io.sentry.force-init"; static final String MAX_BREADCRUMBS = "io.sentry.max-breadcrumbs"; @@ -488,6 +505,91 @@ static void applyMetadata( options.getSessionReplay().setScreenshotStrategy(ScreenshotStrategyType.PIXEL_COPY); } } + + // Network Details Configuration + if (options.getSessionReplay().getNetworkDetailAllowUrls().length == 0) { + final @Nullable List allowUrls = + readList(metadata, logger, REPLAYS_NETWORK_DETAIL_ALLOW_URLS); + if (allowUrls != null && !allowUrls.isEmpty()) { + final List filteredUrls = new ArrayList<>(); + for (String url : allowUrls) { + final String trimmedUrl = url.trim(); + if (!trimmedUrl.isEmpty()) { + filteredUrls.add(trimmedUrl); + } + } + if (!filteredUrls.isEmpty()) { + options + .getSessionReplay() + .setNetworkDetailAllowUrls(filteredUrls.toArray(new String[0])); + } + } + } + + if (options.getSessionReplay().getNetworkDetailDenyUrls().length == 0) { + final @Nullable List denyUrls = + readList(metadata, logger, REPLAYS_NETWORK_DETAIL_DENY_URLS); + if (denyUrls != null && !denyUrls.isEmpty()) { + final List filteredUrls = new ArrayList<>(); + for (String url : denyUrls) { + final String trimmedUrl = url.trim(); + if (!trimmedUrl.isEmpty()) { + filteredUrls.add(trimmedUrl); + } + } + if (!filteredUrls.isEmpty()) { + options + .getSessionReplay() + .setNetworkDetailDenyUrls(filteredUrls.toArray(new String[0])); + } + } + } + + options + .getSessionReplay() + .setNetworkCaptureBodies( + readBool( + metadata, + logger, + REPLAYS_NETWORK_CAPTURE_BODIES, + options.getSessionReplay().isNetworkCaptureBodies() /* defaultValue */)); + + if (options.getSessionReplay().getNetworkRequestHeaders().length + == SentryReplayOptions.getNetworkDetailsDefaultHeaders().length) { // Only has defaults + final @Nullable List requestHeaders = + readList(metadata, logger, REPLAYS_NETWORK_REQUEST_HEADERS); + if (requestHeaders != null) { + final List filteredHeaders = new ArrayList<>(); + for (String header : requestHeaders) { + final String trimmedHeader = header.trim(); + if (!trimmedHeader.isEmpty()) { + filteredHeaders.add(trimmedHeader); + } + } + if (!filteredHeaders.isEmpty()) { + options.getSessionReplay().setNetworkRequestHeaders(filteredHeaders); + } + } + } + + if (options.getSessionReplay().getNetworkResponseHeaders().length + == SentryReplayOptions.getNetworkDetailsDefaultHeaders().length) { // Only has defaults + final @Nullable List responseHeaders = + readList(metadata, logger, REPLAYS_NETWORK_RESPONSE_HEADERS); + if (responseHeaders != null && !responseHeaders.isEmpty()) { + final List filteredHeaders = new ArrayList<>(); + for (String header : responseHeaders) { + final String trimmedHeader = header.trim(); + if (!trimmedHeader.isEmpty()) { + filteredHeaders.add(trimmedHeader); + } + } + if (!filteredHeaders.isEmpty()) { + options.getSessionReplay().setNetworkResponseHeaders(filteredHeaders); + } + } + } + options.setIgnoredErrors(readList(metadata, logger, IGNORED_ERRORS)); final @Nullable List includes = readList(metadata, logger, IN_APP_INCLUDES); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 3c94f0abf29..ed58f268679 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1882,4 +1882,250 @@ class ManifestMetadataReaderTest { fixture.options.sessionReplay.screenshotStrategy, ) } + + // Network Detail Configuration Tests + + @Test + fun `applyMetadata reads comma-separated networkDetailAllowUrls from manifest`() { + // Arrange + val expectedUrls = "https://api.example.com/.*,https://cdn.example.com/.*" + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_NETWORK_DETAIL_ALLOW_URLS to expectedUrls) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val urls = fixture.options.sessionReplay.networkDetailAllowUrls + assertEquals(2, urls.size) + assertEquals("https://api.example.com/.*", urls[0]) + assertEquals("https://cdn.example.com/.*", urls[1]) + } + + @Test + fun `applyMetadata keeps empty networkDetailAllowUrls when not present`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(0, fixture.options.sessionReplay.networkDetailAllowUrls.size) + } + + @Test + fun `applyMetadata reads comma-separated networkDetailDenyUrls from manifest`() { + // Arrange + val expectedUrls = "https://private.example.com/.*,https://internal.example.com/.*" + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_NETWORK_DETAIL_DENY_URLS to expectedUrls) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val urls = fixture.options.sessionReplay.networkDetailDenyUrls + assertEquals(2, urls.size) + assertEquals("https://private.example.com/.*", urls[0]) + assertEquals("https://internal.example.com/.*", urls[1]) + } + + @Test + fun `applyMetadata keeps empty networkDetailDenyUrls when not present`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(0, fixture.options.sessionReplay.networkDetailDenyUrls.size) + } + + @Test + fun `applyMetadata reads networkCaptureBodies from manifest`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_NETWORK_CAPTURE_BODIES to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.sessionReplay.isNetworkCaptureBodies) + } + + @Test + fun `applyMetadata keeps default networkCaptureBodies as true when not present`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.sessionReplay.isNetworkCaptureBodies) + } + + @Test + fun `applyMetadata keeps the default networkRequestHeaders`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val headers = fixture.options.sessionReplay.networkRequestHeaders + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + // Should have exactly the default headers + assertEquals(defaultHeaders.size, headers.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(headers.contains(defaultHeader)) } + } + + @Test + fun `applyMetadata reads networkRequestHeaders from manifest`() { + // Arrange + val expectedHeaders = "Authorization,X-Custom-Header,X-Request-Id" + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_NETWORK_REQUEST_HEADERS to expectedHeaders) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val allHeaders = fixture.options.sessionReplay.networkRequestHeaders + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + // Should include default headers + additional headers + defaultHeaders.forEach { defaultHeader -> + assertTrue(allHeaders.contains(defaultHeader)) // default + } + assertTrue(allHeaders.contains("Authorization")) // additional + assertTrue(allHeaders.contains("X-Custom-Header")) // additional + assertTrue(allHeaders.contains("X-Request-Id")) // additional + } + + @Test + fun `applyMetadata keeps the default networkResponseHeaders`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val headers = fixture.options.sessionReplay.networkResponseHeaders + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + // Should have exactly the default headers + assertEquals(defaultHeaders.size, headers.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(headers.contains(defaultHeader)) } + } + + @Test + fun `applyMetadata reads networkResponseHeaders from manifest`() { + // Arrange + val expectedHeaders = "X-Response-Time,X-Cache-Status,X-Server-Id" + val bundle = + bundleOf(ManifestMetadataReader.REPLAYS_NETWORK_RESPONSE_HEADERS to expectedHeaders) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val allHeaders = fixture.options.sessionReplay.networkResponseHeaders + // Should include default headers + additional headers + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + defaultHeaders.forEach { defaultHeader -> assertTrue(allHeaders.contains(defaultHeader)) } + assertTrue(allHeaders.contains("X-Response-Time")) // additional + assertTrue(allHeaders.contains("X-Cache-Status")) // additional + assertTrue(allHeaders.contains("X-Server-Id")) // additional + } + + @Test + fun `applyMetadata skips empty strings for networkDetailAllowUrls and networkDetailDenyUrls`() { + // Arrange + val bundle = + bundleOf( + ManifestMetadataReader.REPLAYS_NETWORK_DETAIL_ALLOW_URLS to ", ", + ManifestMetadataReader.REPLAYS_NETWORK_DETAIL_DENY_URLS to " ,, ", + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(0, fixture.options.sessionReplay.networkDetailAllowUrls.size) + assertEquals(0, fixture.options.sessionReplay.networkDetailDenyUrls.size) + } + + @Test + fun `applyMetadata skips empty strings for networkRequestHeaders and networkResponseHeaders`() { + // Arrange + val bundle = + bundleOf( + ManifestMetadataReader.REPLAYS_NETWORK_REQUEST_HEADERS to ",", + ManifestMetadataReader.REPLAYS_NETWORK_RESPONSE_HEADERS to " ,", + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + // Should still have default headers even with empty string + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + val requestHeaders = fixture.options.sessionReplay.networkRequestHeaders + assertEquals(defaultHeaders.size, requestHeaders.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(requestHeaders.contains(defaultHeader)) } + + val responseHeaders = fixture.options.sessionReplay.networkResponseHeaders + assertEquals(defaultHeaders.size, responseHeaders.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(responseHeaders.contains(defaultHeader)) } + } + + @Test + fun `applyMetadata trims whitespace from network URLs`() { + // Arrange + val bundle = + bundleOf( + ManifestMetadataReader.REPLAYS_NETWORK_DETAIL_ALLOW_URLS to + " https://api.example.com/.* , https://cdn.example.com/.* " + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val urls = fixture.options.sessionReplay.networkDetailAllowUrls + assertEquals(2, urls.size) + assertEquals("https://api.example.com/.*", urls[0]) + assertEquals("https://cdn.example.com/.*", urls[1]) + } + + @Test + fun `applyMetadata trims whitespace from network headers`() { + // Arrange + val bundle = + bundleOf( + ManifestMetadataReader.REPLAYS_NETWORK_REQUEST_HEADERS to + " Authorization , X-Custom-Header " + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + val headers = fixture.options.sessionReplay.networkRequestHeaders + assertTrue(headers.contains("Authorization")) + assertTrue(headers.contains("X-Custom-Header")) + } } From 46eb26a3db3381061e9a6431dc535fb41c3b7455 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Wed, 12 Nov 2025 15:32:59 -0400 Subject: [PATCH 03/11] Hook SentryOkHttpInterceptor into SentryReplayOptions enables Network Detail extraction via SDK and removes FAKE_OPTIONS placeholder --- .../sentry/okhttp/SentryOkHttpInterceptor.kt | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 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 116d61891a2..bfcc5a033d4 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -61,16 +61,6 @@ public open class SentryOkHttpInterceptor( SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-okhttp", BuildConfig.VERSION_NAME) } - - /** Fake options for testing network detail capture */ - private val FAKE_OPTIONS = - object { - val networkDetailAllowUrls: Array = emptyArray() - val networkDetailDenyUrls: Array = emptyArray() - val networkCaptureBodies: Boolean = false - val networkRequestHeaders: Array = emptyArray() - val networkResponseHeaders: Array = emptyArray() - } } public constructor() : this(ScopesAdapter.getInstance()) @@ -119,8 +109,8 @@ public open class SentryOkHttpInterceptor( NetworkDetailCaptureUtils.initializeForUrl( request.url.toString(), request.method, - FAKE_OPTIONS.networkDetailAllowUrls, - FAKE_OPTIONS.networkDetailDenyUrls, + scopes.options.sessionReplay.networkDetailAllowUrls, + scopes.options.sessionReplay.networkDetailDenyUrls, ) try { @@ -152,7 +142,7 @@ public open class SentryOkHttpInterceptor( NetworkDetailCaptureUtils.createRequest( request, requestContentLength, - FAKE_OPTIONS.networkCaptureBodies, + scopes.options.sessionReplay.isNetworkCaptureBodies, { req -> req.body?.let { originalBody -> val buffer = okio.Buffer() @@ -167,7 +157,7 @@ public open class SentryOkHttpInterceptor( safeExtractRequestBody(bodyBytes, originalBody.contentType(), scopes.options.logger) } }, - FAKE_OPTIONS.networkRequestHeaders, + scopes.options.sessionReplay.networkRequestHeaders, { req: Request -> req.headers.toMap() }, ) ) @@ -211,9 +201,9 @@ public open class SentryOkHttpInterceptor( NetworkDetailCaptureUtils.createResponse( it, it.body?.contentLength(), - FAKE_OPTIONS.networkCaptureBodies, + scopes.options.sessionReplay.isNetworkCaptureBodies, { resp: Response -> resp.extractResponseBody(scopes.options.logger) }, - FAKE_OPTIONS.networkResponseHeaders, + scopes.options.sessionReplay.networkResponseHeaders, { resp: Response -> resp.headers.toMap() }, ), ) From 7232cfa4c0bc6ea5d999ce8e85b1c84b980dd731 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Wed, 12 Nov 2025 15:57:00 -0400 Subject: [PATCH 04/11] Merge requested headers with default headers on write instead of on read Seems more efficient - getNetworkRequestHeaders/getNetworkResponseHeaders is invoked on every http request but setNetwork... is only invoked on start-up --- .../java/io/sentry/SentryReplayOptions.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 3f13e131a62..3b1fcc3a5a9 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -2,7 +2,7 @@ import io.sentry.protocol.SdkVersion; import io.sentry.util.SampleRateUtils; -import java.util.ArrayList; + import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; @@ -197,13 +197,14 @@ 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 List networkRequestHeaders = new ArrayList<>(); + private @NotNull String[] networkRequestHeaders = DEFAULT_HEADERS.clone(); /** * 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 List networkResponseHeaders = new ArrayList<>(); + private @NotNull String[] networkResponseHeaders = DEFAULT_HEADERS.clone(); + public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { @@ -488,38 +489,37 @@ public void setNetworkCaptureBodies(final boolean networkCaptureBodies) { * @return the complete network request headers array */ public @NotNull String[] getNetworkRequestHeaders() { - return mergeHeaders(DEFAULT_HEADERS, networkRequestHeaders); + return networkRequestHeaders; } /** - * Sets additional request headers to capture for URLs defined in networkDetailAllowUrls. The + * Sets request headers to capture for URLs defined in networkDetailAllowUrls. The * default headers (Content-Type, Content-Length, Accept) are always included automatically. * * @param networkRequestHeaders additional network request headers list */ public void setNetworkRequestHeaders(final @NotNull List networkRequestHeaders) { - this.networkRequestHeaders = new ArrayList<>(networkRequestHeaders); + this.networkRequestHeaders = mergeHeaders(DEFAULT_HEADERS, networkRequestHeaders); } /** * Gets all response headers to capture for URLs defined in networkDetailAllowUrls. This includes - * both the default headers (Content-Type, Content-Length, Content-Encoding) and any additional - * headers. + * both the default headers (Content-Type, Content-Length, Accept) and any additional headers. * * @return the complete network response headers array */ public @NotNull String[] getNetworkResponseHeaders() { - return mergeHeaders(DEFAULT_HEADERS, networkResponseHeaders); + return networkResponseHeaders; } /** - * Sets additional response headers to capture for URLs defined in networkDetailAllowUrls. The + * Sets response headers to capture for URLs defined in networkDetailAllowUrls. The * default headers (Content-Type, Content-Length, Accept) are always included automatically. * * @param networkResponseHeaders the additional network response headers list */ public void setNetworkResponseHeaders(final @NotNull List networkResponseHeaders) { - this.networkResponseHeaders = new ArrayList<>(networkResponseHeaders); + this.networkResponseHeaders = mergeHeaders(DEFAULT_HEADERS, networkResponseHeaders); } /** From 7c94ee88d78eb7812d1f8079a33463289f691716 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Wed, 12 Nov 2025 16:04:25 -0400 Subject: [PATCH 05/11] Initialize RRWebOptionsEvent#networkDetailHasUrls based on SentryReplayOptions networkDetailHasUrls is a gate that the front-end uses to determine whether there is data to show the end-user https://github.com/getsentry/sentry-javascript/blob/090a3e35a94014aad4dfd06a6ff3c361f0420009/packages/replay-internal/src/util/handleRecordingEmit.ts#L134 --- sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java index 024a5829226..785eda20134 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -59,6 +59,7 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) { ? "pixelCopy" : "canvas"; optionsPayload.put("screenshotStrategy", screenshotStrategy); + optionsPayload.put("networkDetailHasUrls", replayOptions.getNetworkDetailAllowUrls().length > 0); } @NotNull From cfd7e3775f1b442245a46cfb4fb711f5d24b81b9 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Mon, 17 Nov 2025 12:22:32 -0400 Subject: [PATCH 06/11] Remove defensive copy when returning request|responseHeaders getNetworkRequestHeaders / getNetworkResponseHeaders are called on every http request => move the memory operation to the setNetworkRequest|ResponseHeaders path which is called 1x on start-up --- .../android/core/ManifestMetadataReader.java | 4 +-- sentry/api/sentry.api | 2 +- .../java/io/sentry/SentryReplayOptions.java | 30 +++++++++---------- 3 files changed, 18 insertions(+), 18 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 a8d15fe1702..59facd1a1e4 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 @@ -555,7 +555,7 @@ static void applyMetadata( options.getSessionReplay().isNetworkCaptureBodies() /* defaultValue */)); if (options.getSessionReplay().getNetworkRequestHeaders().length - == SentryReplayOptions.getNetworkDetailsDefaultHeaders().length) { // Only has defaults + == SentryReplayOptions.getNetworkDetailsDefaultHeaders().size()) { // Only has defaults final @Nullable List requestHeaders = readList(metadata, logger, REPLAYS_NETWORK_REQUEST_HEADERS); if (requestHeaders != null) { @@ -573,7 +573,7 @@ static void applyMetadata( } if (options.getSessionReplay().getNetworkResponseHeaders().length - == SentryReplayOptions.getNetworkDetailsDefaultHeaders().length) { // Only has defaults + == SentryReplayOptions.getNetworkDetailsDefaultHeaders().size()) { // Only has defaults final @Nullable List responseHeaders = readList(metadata, logger, REPLAYS_NETWORK_RESPONSE_HEADERS); if (responseHeaders != null && !responseHeaders.isEmpty()) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index a767e849289..decf430b5a5 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3794,7 +3794,7 @@ public final class io/sentry/SentryReplayOptions { public fun getMaskViewContainerClass ()Ljava/lang/String; public fun getNetworkDetailAllowUrls ()[Ljava/lang/String; public fun getNetworkDetailDenyUrls ()[Ljava/lang/String; - public static fun getNetworkDetailsDefaultHeaders ()[Ljava/lang/String; + public static fun getNetworkDetailsDefaultHeaders ()Ljava/util/List; public fun getNetworkRequestHeaders ()[Ljava/lang/String; public fun getNetworkResponseHeaders ()[Ljava/lang/String; public fun getOnErrorSampleRate ()Ljava/lang/Double; diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 3b1fcc3a5a9..bb1538569b8 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -2,8 +2,8 @@ import io.sentry.protocol.SdkVersion; import io.sentry.util.SampleRateUtils; - import java.util.Arrays; +import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -181,30 +181,30 @@ public enum SentryReplayQuality { private boolean networkCaptureBodies = true; /** Default headers that are always captured for URLs defined in networkDetailAllowUrls. */ - private static final @NotNull String[] DEFAULT_HEADERS = - new String[] {"Content-Type", "Content-Length", "Accept"}; + private static final @NotNull List DEFAULT_HEADERS = + Collections.unmodifiableList(Arrays.asList("Content-Type", "Content-Length", "Accept")); /** * Gets the default headers that are always captured for URLs defined in networkDetailAllowUrls. - * Returns a defensive copy to prevent modification. + * + * @return an unmodifiable list */ @ApiStatus.Internal - public static @NotNull String[] getNetworkDetailsDefaultHeaders() { - return DEFAULT_HEADERS.clone(); + public static @NotNull List getNetworkDetailsDefaultHeaders() { + return DEFAULT_HEADERS; } /** * 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.clone(); + private @NotNull String[] networkRequestHeaders = DEFAULT_HEADERS.toArray(new String[0]); /** * 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.clone(); - + private @NotNull String[] networkResponseHeaders = DEFAULT_HEADERS.toArray(new String[0]); public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { @@ -493,8 +493,8 @@ public void setNetworkCaptureBodies(final boolean networkCaptureBodies) { } /** - * Sets request headers to capture for URLs defined in networkDetailAllowUrls. The - * default headers (Content-Type, Content-Length, Accept) are always included automatically. + * Sets request headers to capture for URLs defined in networkDetailAllowUrls. The default headers + * (Content-Type, Content-Length, Accept) are always included automatically. * * @param networkRequestHeaders additional network request headers list */ @@ -513,8 +513,8 @@ public void setNetworkRequestHeaders(final @NotNull List networkRequestH } /** - * Sets response headers to capture for URLs defined in networkDetailAllowUrls. The - * default headers (Content-Type, Content-Length, Accept) are always included automatically. + * Sets response headers to capture for URLs defined in networkDetailAllowUrls. The default + * headers (Content-Type, Content-Length, Accept) are always included automatically. * * @param networkResponseHeaders the additional network response headers list */ @@ -529,9 +529,9 @@ public void setNetworkResponseHeaders(final @NotNull List networkRespons * @param additionalHeaders additional headers to merge */ private static @NotNull String[] mergeHeaders( - final @NotNull String[] defaultHeaders, final @NotNull List additionalHeaders) { + final @NotNull List defaultHeaders, final @NotNull List additionalHeaders) { final Set merged = new LinkedHashSet<>(); - merged.addAll(Arrays.asList(defaultHeaders)); + merged.addAll(defaultHeaders); merged.addAll(additionalHeaders); return merged.toArray(new String[0]); } From e4c361e67dba3a9db12138b4f761f65ab532b5da Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 17 Nov 2025 16:26:10 +0000 Subject: [PATCH 07/11] Format code --- sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java index 785eda20134..922e9f4608c 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -59,7 +59,8 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) { ? "pixelCopy" : "canvas"; optionsPayload.put("screenshotStrategy", screenshotStrategy); - optionsPayload.put("networkDetailHasUrls", replayOptions.getNetworkDetailAllowUrls().length > 0); + optionsPayload.put( + "networkDetailHasUrls", replayOptions.getNetworkDetailAllowUrls().length > 0); } @NotNull From 097342a38dd9fadebb07041cb6b8b4b7579899c8 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Mon, 17 Nov 2025 16:26:40 -0400 Subject: [PATCH 08/11] Add the network details options as 'tags' on the replay --- .../io/sentry/rrweb/RRWebOptionsEvent.java | 13 +++ .../RRWebOptionsEventSerializationTest.kt | 98 +++++++++++++++++++ .../resources/json/rrweb_options_event.json | 1 + 3 files changed, 112 insertions(+) diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java index 922e9f4608c..d61dd320e0f 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -61,6 +61,19 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) { optionsPayload.put("screenshotStrategy", screenshotStrategy); optionsPayload.put( "networkDetailHasUrls", replayOptions.getNetworkDetailAllowUrls().length > 0); + + // Add network detail configuration options + if (replayOptions.getNetworkDetailAllowUrls().length > 0) { + 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) { + optionsPayload.put("networkDetailDenyUrls", replayOptions.getNetworkDetailDenyUrls()); + } + } } @NotNull diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt index a9ed89a31c9..1536a16275a 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt @@ -6,6 +6,9 @@ import io.sentry.SentryReplayOptions.SentryReplayQuality.LOW import io.sentry.protocol.SdkVersion import io.sentry.protocol.SerializationUtils import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.assertContentEquals import org.junit.Test import org.mockito.kotlin.mock @@ -48,4 +51,99 @@ class RRWebOptionsEventSerializationTest { val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) assertEquals(expectedJson, actualJson) } + + @Test + fun `network detail fields are not included when networkDetailAllowUrls is empty`() { + val options = SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(emptyArray()) + + // Any config is ignored when no allowUrls are specified. + sessionReplay.setNetworkDetailDenyUrls(arrayOf("https://internal.example.com/*")) + sessionReplay.setNetworkRequestHeaders(listOf("Authorization", "X-Custom")) + sessionReplay.setNetworkResponseHeaders(listOf("X-RateLimit", "Content-Type")) + } + val event = RRWebOptionsEvent(options) + + val payload = event.optionsPayload + assertFalse(payload.containsKey("networkDetailAllowUrls")) + assertFalse(payload.containsKey("networkDetailDenyUrls")) + assertFalse(payload.containsKey("networkRequestHeaders")) + assertFalse(payload.containsKey("networkResponseHeaders")) + assertFalse(payload.containsKey("networkCaptureBodies")) + assertEquals(false, payload["networkDetailHasUrls"]) + } + + @Test + fun `networkDetailAllowUrls and headers are included when networkDetailAllowUrls is configured`() { + val options = SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + sessionReplay.setNetworkRequestHeaders(listOf("Authorization", "X-Custom")) + sessionReplay.setNetworkResponseHeaders(listOf("X-RateLimit", "Content-Type")) + } + val event = RRWebOptionsEvent(options) + + val payload = event.optionsPayload + assertTrue(payload.containsKey("networkDetailAllowUrls")) + assertTrue(payload.containsKey("networkRequestHeaders")) + assertTrue(payload.containsKey("networkResponseHeaders")) + assertEquals(true, payload["networkDetailHasUrls"]) + assertContentEquals(arrayOf("https://api.example.com/*"), payload["networkDetailAllowUrls"] as Array) + assertContentEquals(arrayOf("Content-Type", "Content-Length", "Accept", "Authorization", "X-Custom"), payload["networkRequestHeaders"] as Array) + assertContentEquals(arrayOf("Content-Type", "Content-Length", "Accept", "X-RateLimit"), payload["networkResponseHeaders"] as Array) + } + + @Test + 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/*")) + } + 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) + assertContentEquals(arrayOf("https://internal.example.com/*"), payload["networkDetailDenyUrls"] as Array) + } + + @Test + fun `networkCaptureBodies is included when networkDetailAllowUrls is configured`() { + val options = SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + sessionReplay.setNetworkCaptureBodies(false) + } + val event = RRWebOptionsEvent(options) + + val payload = event.optionsPayload + assertTrue(payload.containsKey("networkCaptureBodies")) + assertEquals(false, payload["networkCaptureBodies"]) + } + + @Test + fun `default networkCaptureBodies is included when networkDetailAllowUrls is configured`() { + val options = SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + } + val event = RRWebOptionsEvent(options) + + val payload = event.optionsPayload + assertTrue(payload.containsKey("networkCaptureBodies")) + assertEquals(true, payload["networkCaptureBodies"]) + } + + @Test + 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/*")) + // No custom headers set, should use defaults only + } + val event = RRWebOptionsEvent(options) + + val payload = event.optionsPayload + assertTrue(payload.containsKey("networkRequestHeaders")) + assertTrue(payload.containsKey("networkResponseHeaders")) + assertContentEquals(arrayOf("Content-Type", "Content-Length", "Accept"), payload["networkRequestHeaders"] as Array) + assertContentEquals(arrayOf("Content-Type", "Content-Length", "Accept"), payload["networkResponseHeaders"] as Array) + } } diff --git a/sentry/src/test/resources/json/rrweb_options_event.json b/sentry/src/test/resources/json/rrweb_options_event.json index ab777e6b72a..a66605fc372 100644 --- a/sentry/src/test/resources/json/rrweb_options_event.json +++ b/sentry/src/test/resources/json/rrweb_options_event.json @@ -7,6 +7,7 @@ "unmaskedViewClasses": ["com.example.MyClass"], "nativeSdkVersion": "7.19.1", "errorSampleRate": 0.1, + "networkDetailHasUrls": false, "maskAllImages": false, "maskAllText": false, "maskedViewClasses": [], From 2a05036a8c74f873f4d90916bcb22f7bdca0b141 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Mon, 17 Nov 2025 17:04:25 -0400 Subject: [PATCH 09/11] Add network details flags in manifest for sentry-samples test app --- .../src/main/AndroidManifest.xml | 11 ++++++ .../android/TriggerHttpRequestActivity.java | 38 ++++++++++++++++--- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e21bead746d..2f137493b08 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -192,5 +192,16 @@ + + + + + + + + + + + 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 4d2869d1f14..486b558a717 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 @@ -117,6 +117,10 @@ private void performGetRequest() { .get() .addHeader("User-Agent", "Sentry-Sample-Android") .addHeader("Accept", "application/json") + // Test headers for network detail filtering + .addHeader("Authorization", "Bearer test-token-12345") + .addHeader("X-Custom-Header", "custom-value-for-testing") + .addHeader("X-Test-Request", "network-detail-test") .build(); displayRequest("GET", request); @@ -196,10 +200,23 @@ public void onResponse(Call call, Response response) throws IOException { final long responseTime = System.currentTimeMillis() - startTime; final String finalBody = body; + // Capture response headers for network detail testing + final StringBuilder responseHeaders = new StringBuilder(); + for (int i = 0; i < response.headers().size(); i++) { + responseHeaders + .append(" ") + .append(response.headers().name(i)) + .append(": ") + .append(response.headers().value(i)) + .append("\n"); + } + final String finalResponseHeaders = responseHeaders.toString(); + runOnUiThread( () -> { showLoading(false); - displayResponse(statusMessage, statusCode, finalBody, responseTime); + displayResponse( + statusMessage, statusCode, finalBody, responseTime, finalResponseHeaders); }); response.close(); @@ -237,27 +254,36 @@ private void displayRequest(String method, Request request, String body) { } private void displayResponse(String status, Integer code, String body, long responseTime) { + displayResponse(status, code, body, responseTime, null); + } + + private void displayResponse( + String status, Integer code, String body, long responseTime, String headers) { StringBuilder sb = new StringBuilder(); sb.append("[").append(getCurrentTime()).append("]\n"); sb.append("━━━━━━━━━━━━━━━━━━━━━━━━\n"); if (code != null) { sb.append("STATUS: ").append(code).append(" ").append(status).append("\n"); - sb.append("RESPONSE TIME: ").append(responseTime).append("ms\n\n"); + sb.append("RESPONSE TIME: ").append(responseTime).append("ms\n"); } else { - sb.append("STATUS: ").append(status).append("\n\n"); + sb.append("STATUS: ").append(status).append("\n"); + } + + if (headers != null && !headers.isEmpty()) { + sb.append("\nRESPONSE HEADERS:\n").append(headers); } if (body != null && !body.isEmpty()) { try { if (body.trim().startsWith("{") || body.trim().startsWith("[")) { JSONObject json = new JSONObject(body); - sb.append("BODY (JSON):\n").append(json.toString(2)); + sb.append("\nBODY (JSON):\n").append(json.toString(2)); } else { - sb.append("BODY:\n").append(body); + sb.append("\nBODY:\n").append(body); } } catch (Exception e) { - sb.append("BODY:\n").append(body); + sb.append("\nBODY:\n").append(body); } } From cf3426898fbbfc3d7deb6440753a9df3421bf942 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 17 Nov 2025 21:14:16 +0000 Subject: [PATCH 10/11] Format code --- .../RRWebOptionsEventSerializationTest.kt | 99 ++++++++++++------- 1 file changed, 63 insertions(+), 36 deletions(-) diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt index 1536a16275a..bcfed3ede6b 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt @@ -5,10 +5,10 @@ import io.sentry.SentryOptions 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 -import kotlin.test.assertContentEquals import org.junit.Test import org.mockito.kotlin.mock @@ -54,14 +54,15 @@ class RRWebOptionsEventSerializationTest { @Test fun `network detail fields are not included when networkDetailAllowUrls is empty`() { - val options = SentryOptions().apply { - sessionReplay.setNetworkDetailAllowUrls(emptyArray()) - - // Any config is ignored when no allowUrls are specified. - sessionReplay.setNetworkDetailDenyUrls(arrayOf("https://internal.example.com/*")) - sessionReplay.setNetworkRequestHeaders(listOf("Authorization", "X-Custom")) - sessionReplay.setNetworkResponseHeaders(listOf("X-RateLimit", "Content-Type")) - } + val options = + SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(emptyArray()) + + // Any config is ignored when no allowUrls are specified. + sessionReplay.setNetworkDetailDenyUrls(arrayOf("https://internal.example.com/*")) + sessionReplay.setNetworkRequestHeaders(listOf("Authorization", "X-Custom")) + sessionReplay.setNetworkResponseHeaders(listOf("X-RateLimit", "Content-Type")) + } val event = RRWebOptionsEvent(options) val payload = event.optionsPayload @@ -75,11 +76,12 @@ class RRWebOptionsEventSerializationTest { @Test fun `networkDetailAllowUrls and headers are included when networkDetailAllowUrls is configured`() { - val options = SentryOptions().apply { - sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) - sessionReplay.setNetworkRequestHeaders(listOf("Authorization", "X-Custom")) - sessionReplay.setNetworkResponseHeaders(listOf("X-RateLimit", "Content-Type")) - } + val options = + SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + sessionReplay.setNetworkRequestHeaders(listOf("Authorization", "X-Custom")) + sessionReplay.setNetworkResponseHeaders(listOf("X-RateLimit", "Content-Type")) + } val event = RRWebOptionsEvent(options) val payload = event.optionsPayload @@ -87,32 +89,49 @@ class RRWebOptionsEventSerializationTest { assertTrue(payload.containsKey("networkRequestHeaders")) assertTrue(payload.containsKey("networkResponseHeaders")) assertEquals(true, payload["networkDetailHasUrls"]) - assertContentEquals(arrayOf("https://api.example.com/*"), payload["networkDetailAllowUrls"] as Array) - assertContentEquals(arrayOf("Content-Type", "Content-Length", "Accept", "Authorization", "X-Custom"), payload["networkRequestHeaders"] as Array) - assertContentEquals(arrayOf("Content-Type", "Content-Length", "Accept", "X-RateLimit"), payload["networkResponseHeaders"] as Array) + assertContentEquals( + arrayOf("https://api.example.com/*"), + payload["networkDetailAllowUrls"] as Array, + ) + assertContentEquals( + arrayOf("Content-Type", "Content-Length", "Accept", "Authorization", "X-Custom"), + payload["networkRequestHeaders"] as Array, + ) + assertContentEquals( + arrayOf("Content-Type", "Content-Length", "Accept", "X-RateLimit"), + payload["networkResponseHeaders"] as Array, + ) } @Test 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/*")) - } + val options = + SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + sessionReplay.setNetworkDetailDenyUrls(arrayOf("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) - assertContentEquals(arrayOf("https://internal.example.com/*"), payload["networkDetailDenyUrls"] as Array) + assertContentEquals( + arrayOf("https://api.example.com/*"), + payload["networkDetailAllowUrls"] as Array, + ) + assertContentEquals( + arrayOf("https://internal.example.com/*"), + payload["networkDetailDenyUrls"] as Array, + ) } @Test fun `networkCaptureBodies is included when networkDetailAllowUrls is configured`() { - val options = SentryOptions().apply { - sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) - sessionReplay.setNetworkCaptureBodies(false) - } + val options = + SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + sessionReplay.setNetworkCaptureBodies(false) + } val event = RRWebOptionsEvent(options) val payload = event.optionsPayload @@ -122,9 +141,10 @@ class RRWebOptionsEventSerializationTest { @Test fun `default networkCaptureBodies is included when networkDetailAllowUrls is configured`() { - val options = SentryOptions().apply { - sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) - } + val options = + SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + } val event = RRWebOptionsEvent(options) val payload = event.optionsPayload @@ -134,16 +154,23 @@ class RRWebOptionsEventSerializationTest { @Test 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/*")) - // No custom headers set, should use defaults only - } + val options = + SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(arrayOf("https://api.example.com/*")) + // No custom headers set, should use defaults only + } val event = RRWebOptionsEvent(options) val payload = event.optionsPayload assertTrue(payload.containsKey("networkRequestHeaders")) assertTrue(payload.containsKey("networkResponseHeaders")) - assertContentEquals(arrayOf("Content-Type", "Content-Length", "Accept"), payload["networkRequestHeaders"] as Array) - assertContentEquals(arrayOf("Content-Type", "Content-Length", "Accept"), payload["networkResponseHeaders"] as Array) + assertContentEquals( + arrayOf("Content-Type", "Content-Length", "Accept"), + payload["networkRequestHeaders"] as Array, + ) + assertContentEquals( + arrayOf("Content-Type", "Content-Length", "Accept"), + payload["networkResponseHeaders"] as Array, + ) } } From 75a4e6cf6b0188995369b81a9b41c0c405167d98 Mon Sep 17 00:00:00 2001 From: Matthew Williams <43.matthew@gmail.com> Date: Mon, 17 Nov 2025 17:24:47 -0400 Subject: [PATCH 11/11] Remove spurious request-headers from sample app --- .../sentry-samples-android/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 2f137493b08..beca2363f83 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -201,7 +201,7 @@ - +