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..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 @@ -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().size()) { // 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().size()) { // 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")) + } } 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() }, ), ) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e21bead746d..beca2363f83 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); } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index b3f7a5c2752..a638df4ebef 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3800,6 +3800,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/util/List; + 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; @@ -3810,6 +3815,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 @@ -3817,6 +3823,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..bb1538569b8 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.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Locale; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; @@ -157,6 +161,51 @@ 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 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. + * + * @return an unmodifiable list + */ + @ApiStatus.Internal + 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.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.toArray(new String[0]); + public SentryReplayOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) { if (!empty) { setMaskAllText(true); @@ -377,4 +426,113 @@ 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 networkRequestHeaders; + } + + /** + * 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 = 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, Accept) and any additional headers. + * + * @return the complete network response headers array + */ + public @NotNull String[] getNetworkResponseHeaders() { + return networkResponseHeaders; + } + + /** + * 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 = mergeHeaders(DEFAULT_HEADERS, 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 List defaultHeaders, final @NotNull List additionalHeaders) { + final Set merged = new LinkedHashSet<>(); + merged.addAll(defaultHeaders); + merged.addAll(additionalHeaders); + return merged.toArray(new String[0]); + } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java index 024a5829226..d61dd320e0f 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -59,6 +59,21 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) { ? "pixelCopy" : "canvas"; 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/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")) + } } diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt index a9ed89a31c9..bcfed3ede6b 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt @@ -5,7 +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 org.junit.Test import org.mockito.kotlin.mock @@ -48,4 +51,126 @@ 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": [],