From f83d56c0b8f5e7af8e197a169b342c3da9f02822 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 24 Jul 2026 14:28:09 +0200 Subject: [PATCH] feat(replay): Inherit network collection policy Let nullable Session Replay network options inherit matching Data Collection settings while preserving historical Replay defaults when Data Collection is absent. Keep explicit Replay options authoritative and apply the effective policies to OkHttp network details. Co-Authored-By: Claude --- .../android/core/ManifestMetadataReader.java | 80 ++++---- .../core/ManifestMetadataReaderTest.kt | 72 +++---- .../sentry/okhttp/SentryOkHttpInterceptor.kt | 16 +- sentry/api/sentry.api | 12 ++ .../java/io/sentry/SentryReplayOptions.java | 183 ++++++++++++++---- .../io/sentry/rrweb/RRWebOptionsEvent.java | 28 ++- .../network/NetworkDetailCaptureUtils.java | 84 +++++--- .../java/io/sentry/SentryReplayOptionsTest.kt | 176 +++++++++++++---- .../RRWebOptionsEventSerializationTest.kt | 18 ++ .../network/NetworkDetailCaptureUtilsTest.kt | 102 +++++----- 10 files changed, 521 insertions(+), 250 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 7a9cd8a4d13..ebcfb5de5ba 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,7 +10,6 @@ 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; @@ -639,47 +638,20 @@ static void applyMetadata( options .getSessionReplay() .setNetworkCaptureBodies( - readBool( + readBoolNullable( metadata, logger, REPLAYS_NETWORK_CAPTURE_BODIES, - options.getSessionReplay().isNetworkCaptureBodies() /* defaultValue */)); - - if (options.getSessionReplay().getNetworkRequestHeaders().size() - == 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); - } - } - } + options.getSessionReplay().getNetworkCaptureBodies())); - if (options.getSessionReplay().getNetworkResponseHeaders().size() - == 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 + .getSessionReplay() + .setNetworkRequestHeaders( + readTrimmedList(metadata, logger, REPLAYS_NETWORK_REQUEST_HEADERS)); + options + .getSessionReplay() + .setNetworkResponseHeaders( + readTrimmedList(metadata, logger, REPLAYS_NETWORK_RESPONSE_HEADERS)); options.setIgnoredErrors(readList(metadata, logger, IGNORED_ERRORS)); @@ -783,6 +755,21 @@ private static boolean readBool( return value; } + private static @Nullable Boolean readBoolNullable( + final @NotNull Bundle metadata, + final @NotNull ILogger logger, + final @NotNull String key, + final @Nullable Boolean defaultValue) { + final @Nullable Boolean value; + if (metadata.containsKey(key)) { + value = metadata.getBoolean(key); + } else { + value = defaultValue; + } + logger.log(SentryLevel.DEBUG, key + " read: " + value); + return value; + } + private static @Nullable String readString( final @NotNull Bundle metadata, final @NotNull ILogger logger, @@ -814,6 +801,23 @@ private static boolean readBool( } } + private static @Nullable List readTrimmedList( + final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { + final @Nullable List values = readList(metadata, logger, key); + if (values == null) { + return null; + } + + final @NotNull List filteredValues = new ArrayList<>(); + for (final String value : values) { + final @NotNull String trimmedValue = value.trim(); + if (!trimmedValue.isEmpty()) { + filteredValues.add(trimmedValue); + } + } + return filteredValues.isEmpty() ? null : filteredValues; + } + private static double readDouble( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { // manifest meta-data only reads float 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 d0dbd1deb50..1760aaee6ab 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 @@ -6,6 +6,7 @@ import androidx.core.os.bundleOf import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.FilterString import io.sentry.ILogger +import io.sentry.KeyValueCollectionBehavior import io.sentry.ProfileLifecycle import io.sentry.SentryLevel import io.sentry.SentryReplayOptions @@ -2347,11 +2348,11 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertFalse(fixture.options.sessionReplay.isNetworkCaptureBodies) + assertEquals(false, fixture.options.sessionReplay.networkCaptureBodies) } @Test - fun `applyMetadata keeps default networkCaptureBodies as true when not present`() { + fun `applyMetadata keeps networkCaptureBodies unset when not present`() { // Arrange val context = fixture.getContext() @@ -2359,11 +2360,11 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.sessionReplay.isNetworkCaptureBodies) + assertNull(fixture.options.sessionReplay.networkCaptureBodies) } @Test - fun `applyMetadata keeps the default networkRequestHeaders`() { + fun `applyMetadata keeps networkRequestHeaderBehavior unset when not present`() { // Arrange val context = fixture.getContext() @@ -2371,12 +2372,7 @@ class ManifestMetadataReaderTest { 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)) } + assertNull(fixture.options.sessionReplay.networkRequestHeaderBehavior) } @Test @@ -2390,20 +2386,16 @@ class ManifestMetadataReaderTest { 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 + val behavior = fixture.options.sessionReplay.networkRequestHeaderBehavior + assertEquals(KeyValueCollectionBehavior.Mode.ALLOW_LIST, behavior?.mode) + assertTrue(behavior!!.terms.contains("Content-Type")) + assertTrue(behavior.terms.contains("Authorization")) + assertTrue(behavior.terms.contains("X-Custom-Header")) + assertTrue(behavior.terms.contains("X-Request-Id")) } @Test - fun `applyMetadata keeps the default networkResponseHeaders`() { + fun `applyMetadata keeps networkResponseHeaderBehavior unset when not present`() { // Arrange val context = fixture.getContext() @@ -2411,12 +2403,7 @@ class ManifestMetadataReaderTest { 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)) } + assertNull(fixture.options.sessionReplay.networkResponseHeaderBehavior) } @Test @@ -2431,13 +2418,12 @@ class ManifestMetadataReaderTest { 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 + val behavior = fixture.options.sessionReplay.networkResponseHeaderBehavior + assertEquals(KeyValueCollectionBehavior.Mode.ALLOW_LIST, behavior?.mode) + assertTrue(behavior!!.terms.contains("Content-Type")) + assertTrue(behavior.terms.contains("X-Response-Time")) + assertTrue(behavior.terms.contains("X-Cache-Status")) + assertTrue(behavior.terms.contains("X-Server-Id")) } @Test @@ -2472,16 +2458,8 @@ class ManifestMetadataReaderTest { 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)) } + assertNull(fixture.options.sessionReplay.networkRequestHeaderBehavior) + assertNull(fixture.options.sessionReplay.networkResponseHeaderBehavior) } @Test @@ -2518,9 +2496,9 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - val headers = fixture.options.sessionReplay.networkRequestHeaders - assertTrue(headers.contains("Authorization")) - assertTrue(headers.contains("X-Custom-Header")) + val behavior = fixture.options.sessionReplay.networkRequestHeaderBehavior + assertTrue(behavior!!.terms.contains("Authorization")) + assertTrue(behavior.terms.contains("X-Custom-Header")) } // Spotlight Configuration Tests 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 ed704966610..1928f30ff88 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -146,7 +146,9 @@ public open class SentryOkHttpInterceptor( NetworkDetailCaptureUtils.createRequest( request, requestContentLength, - scopes.options.sessionReplay.isNetworkCaptureBodies, + scopes.options.sessionReplay.isNetworkRequestBodyCaptureEnabled( + scopes.options.dataCollectionResolver + ), { req -> req.body?.let { originalBody -> val buffer = okio.Buffer() @@ -161,7 +163,9 @@ public open class SentryOkHttpInterceptor( safeExtractRequestBody(bodyBytes, originalBody.contentType(), scopes.options.logger) } }, - scopes.options.sessionReplay.networkRequestHeaders, + scopes.options.sessionReplay.resolveNetworkRequestHeaders( + scopes.options.dataCollectionResolver + ), { req: Request -> req.headers.toMap() }, ) ) @@ -205,9 +209,13 @@ public open class SentryOkHttpInterceptor( NetworkDetailCaptureUtils.createResponse( it, it.body?.contentLength(), - scopes.options.sessionReplay.isNetworkCaptureBodies, + scopes.options.sessionReplay.isNetworkResponseBodyCaptureEnabled( + scopes.options.dataCollectionResolver + ), { resp: Response -> resp.extractResponseBody(scopes.options.logger) }, - scopes.options.sessionReplay.networkResponseHeaders, + scopes.options.sessionReplay.resolveNetworkResponseHeaders( + scopes.options.dataCollectionResolver + ), { resp: Response -> resp.headers.toMap() }, ), ) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 55d5a34e3ba..2a4ab4f0c1a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4171,10 +4171,13 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun getErrorReplayDuration ()J public fun getFrameObserver ()Lio/sentry/SentryReplayOptions$ReplayFrameObserver; public fun getFrameRate ()I + public fun getNetworkCaptureBodies ()Ljava/lang/Boolean; public fun getNetworkDetailAllowUrls ()Ljava/util/List; public fun getNetworkDetailDenyUrls ()Ljava/util/List; public static fun getNetworkDetailsDefaultHeaders ()Ljava/util/List; + public fun getNetworkRequestHeaderBehavior ()Lio/sentry/KeyValueCollectionBehavior; public fun getNetworkRequestHeaders ()Ljava/util/List; + public fun getNetworkResponseHeaderBehavior ()Lio/sentry/KeyValueCollectionBehavior; public fun getNetworkResponseHeaders ()Ljava/util/List; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; @@ -4186,19 +4189,26 @@ public final class io/sentry/SentryReplayOptions : io/sentry/SentryMaskingOption public fun isCaptureSurfaceViews ()Z public fun isDebug ()Z public fun isNetworkCaptureBodies ()Z + public fun isNetworkRequestBodyCaptureEnabled (Lio/sentry/DataCollectionResolver;)Z + public fun isNetworkResponseBodyCaptureEnabled (Lio/sentry/DataCollectionResolver;)Z public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z public fun isTrackConfiguration ()Z + public fun resolveNetworkRequestHeaders (Lio/sentry/DataCollectionResolver;)Lio/sentry/KeyValueCollectionBehavior; + public fun resolveNetworkResponseHeaders (Lio/sentry/DataCollectionResolver;)Lio/sentry/KeyValueCollectionBehavior; public fun setBeforeErrorSampling (Lio/sentry/SentryReplayOptions$BeforeErrorSamplingCallback;)V public fun setCaptureSurfaceViews (Z)V public fun setDebug (Z)V public fun setFrameObserver (Lio/sentry/SentryReplayOptions$ReplayFrameObserver;)V public fun setMaskAllImages (Z)V public fun setMaskAllText (Z)V + public fun setNetworkCaptureBodies (Ljava/lang/Boolean;)V public fun setNetworkCaptureBodies (Z)V public fun setNetworkDetailAllowUrls (Ljava/util/List;)V public fun setNetworkDetailDenyUrls (Ljava/util/List;)V + public fun setNetworkRequestHeaderBehavior (Lio/sentry/KeyValueCollectionBehavior;)V public fun setNetworkRequestHeaders (Ljava/util/List;)V + public fun setNetworkResponseHeaderBehavior (Lio/sentry/KeyValueCollectionBehavior;)V public fun setNetworkResponseHeaders (Ljava/util/List;)V public fun setOnErrorSampleRate (Ljava/lang/Double;)V public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V @@ -8113,7 +8123,9 @@ public final class io/sentry/util/network/NetworkBodyParser { } public final class io/sentry/util/network/NetworkDetailCaptureUtils { + public static fun createRequest (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Lio/sentry/KeyValueCollectionBehavior;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; public static fun createRequest (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; + public static fun createResponse (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Lio/sentry/KeyValueCollectionBehavior;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; public static fun createResponse (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse; public static fun initializeForUrl (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lio/sentry/util/network/NetworkRequestData; } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index d1da6510cdb..8d53c37d0d9 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -197,11 +197,10 @@ public enum SentryReplayQuality { private @NotNull List networkDetailDenyUrls = Collections.emptyList(); /** - * Decide whether to capture request and response bodies for URLs defined in - * networkDetailAllowUrls. Default is true, but capturing bodies requires at least one url - * specified via {@link #setNetworkDetailAllowUrls(List)}. + * Explicitly controls whether to capture request and response bodies for URLs defined in + * networkDetailAllowUrls. A null value inherits from Data Collection or the legacy default. */ - private boolean networkCaptureBodies = true; + private @Nullable Boolean networkCaptureBodies; /** Default headers that are always captured for URLs defined in networkDetailAllowUrls. */ private static final @NotNull List DEFAULT_HEADERS = @@ -217,17 +216,11 @@ public enum SentryReplayQuality { 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 List networkRequestHeaders = DEFAULT_HEADERS; + /** Explicit request-header collection behavior, or null to inherit. */ + private @Nullable KeyValueCollectionBehavior networkRequestHeaderBehavior; - /** - * 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 = DEFAULT_HEADERS; + /** Explicit response-header collection behavior, or null to inherit. */ + private @Nullable KeyValueCollectionBehavior networkResponseHeaderBehavior; /** * A callback that is called before the error sample rate is checked for session replay. Can be @@ -482,62 +475,184 @@ public void setNetworkDetailDenyUrls(final @NotNull List networkDetailDe Collections.unmodifiableList(new ArrayList<>(networkDetailDenyUrls)); } + /** + * Gets whether Session Replay explicitly enables or disables request and response body capture. A + * {@code null} value inherits the matching Data Collection option, or the legacy default when + * Data Collection is not configured. + */ + public @Nullable Boolean getNetworkCaptureBodies() { + return networkCaptureBodies; + } + /** * Gets whether to capture request and response bodies for URLs defined in networkDetailAllowUrls. * - * @return true if network capture bodies is enabled, false otherwise + * @return the explicit value, or the legacy default of {@code true} when unset + * @deprecated Use {@link #getNetworkCaptureBodies()} to distinguish an explicit value from + * inheritance. */ + @Deprecated public boolean isNetworkCaptureBodies() { - return networkCaptureBodies; + return networkCaptureBodies == null || networkCaptureBodies; + } + + /** + * Sets whether to capture request and response bodies for URLs defined in networkDetailAllowUrls. + * A {@code null} value inherits from Data Collection. + */ + public void setNetworkCaptureBodies(final @Nullable Boolean networkCaptureBodies) { + this.networkCaptureBodies = 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. + * Gets the explicit request-header collection behavior. A {@code null} value inherits the Data + * Collection request-header behavior, or the legacy default when Data Collection is not + * configured. + */ + public @Nullable KeyValueCollectionBehavior getNetworkRequestHeaderBehavior() { + return networkRequestHeaderBehavior; + } + + /** Sets the explicit request-header collection behavior, or {@code null} to inherit. */ + public void setNetworkRequestHeaderBehavior( + final @Nullable KeyValueCollectionBehavior networkRequestHeaderBehavior) { + this.networkRequestHeaderBehavior = networkRequestHeaderBehavior; + } + + /** + * Gets request header allow-list terms for URLs defined in networkDetailAllowUrls. * - * @return an unmodifiable list of the request headers to extract + * @return the configured allow-list, the legacy default headers when unset, or an empty list when + * the configured behavior cannot be represented as an allow-list + * @deprecated Use {@link #getNetworkRequestHeaderBehavior()} to retain the collection mode. */ + @Deprecated public @NotNull List getNetworkRequestHeaders() { - return networkRequestHeaders; + return getLegacyHeaderList(networkRequestHeaderBehavior); } /** * Sets request headers to capture for URLs defined in networkDetailAllowUrls. The default headers - * (Content-Type, Content-Length, Accept) are always included automatically. + * (Content-Type, Content-Length, Accept) are always included automatically. Pass {@code null} to + * inherit from Data Collection. * - * @param networkRequestHeaders additional network request headers list + * @deprecated Use {@link #setNetworkRequestHeaderBehavior(KeyValueCollectionBehavior)}. + */ + @Deprecated + public void setNetworkRequestHeaders(final @Nullable List networkRequestHeaders) { + this.networkRequestHeaderBehavior = + networkRequestHeaders == null + ? null + : KeyValueCollectionBehavior.allowList( + mergeHeaders(DEFAULT_HEADERS, networkRequestHeaders).toArray(new String[0])); + } + + /** + * Gets the explicit response-header collection behavior. A {@code null} value inherits the Data + * Collection response-header behavior, or the legacy default when Data Collection is not + * configured. */ - public void setNetworkRequestHeaders(final @NotNull List networkRequestHeaders) { - this.networkRequestHeaders = mergeHeaders(DEFAULT_HEADERS, networkRequestHeaders); + public @Nullable KeyValueCollectionBehavior getNetworkResponseHeaderBehavior() { + return networkResponseHeaderBehavior; + } + + /** Sets the explicit response-header collection behavior, or {@code null} to inherit. */ + public void setNetworkResponseHeaderBehavior( + final @Nullable KeyValueCollectionBehavior networkResponseHeaderBehavior) { + this.networkResponseHeaderBehavior = networkResponseHeaderBehavior; } /** - * 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. + * Gets response header allow-list terms for URLs defined in networkDetailAllowUrls. * - * @return an unmodifiable list of the response headers to extract + * @return the configured allow-list, the legacy default headers when unset, or an empty list when + * the configured behavior cannot be represented as an allow-list + * @deprecated Use {@link #getNetworkResponseHeaderBehavior()} to retain the collection mode. */ + @Deprecated public @NotNull List getNetworkResponseHeaders() { - return networkResponseHeaders; + return getLegacyHeaderList(networkResponseHeaderBehavior); } /** * Sets response headers to capture for URLs defined in networkDetailAllowUrls. The default - * headers (Content-Type, Content-Length, Accept) are always included automatically. + * headers (Content-Type, Content-Length, Accept) are always included automatically. Pass {@code + * null} to inherit from Data Collection. * - * @param networkResponseHeaders the additional network response headers list + * @deprecated Use {@link #setNetworkResponseHeaderBehavior(KeyValueCollectionBehavior)}. */ - public void setNetworkResponseHeaders(final @NotNull List networkResponseHeaders) { - this.networkResponseHeaders = mergeHeaders(DEFAULT_HEADERS, networkResponseHeaders); + @Deprecated + public void setNetworkResponseHeaders(final @Nullable List networkResponseHeaders) { + this.networkResponseHeaderBehavior = + networkResponseHeaders == null + ? null + : KeyValueCollectionBehavior.allowList( + mergeHeaders(DEFAULT_HEADERS, networkResponseHeaders).toArray(new String[0])); + } + + private static @NotNull List getLegacyHeaderList( + final @Nullable KeyValueCollectionBehavior behavior) { + if (behavior == null) { + return DEFAULT_HEADERS; + } + return behavior.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST + ? behavior.getTerms() + : Collections.emptyList(); + } + + @ApiStatus.Internal + public boolean isNetworkRequestBodyCaptureEnabled( + final @NotNull DataCollectionResolver dataCollectionResolver) { + if (networkCaptureBodies != null) { + return networkCaptureBodies; + } + return dataCollectionResolver.isDataCollectionConfigured() + ? dataCollectionResolver.isOutgoingRequestBody() + : true; + } + + @ApiStatus.Internal + public boolean isNetworkResponseBodyCaptureEnabled( + final @NotNull DataCollectionResolver dataCollectionResolver) { + if (networkCaptureBodies != null) { + return networkCaptureBodies; + } + return dataCollectionResolver.isDataCollectionConfigured() + ? dataCollectionResolver.isIncomingResponseBody() + : true; + } + + @ApiStatus.Internal + public @NotNull KeyValueCollectionBehavior resolveNetworkRequestHeaders( + final @NotNull DataCollectionResolver dataCollectionResolver) { + if (networkRequestHeaderBehavior != null) { + return networkRequestHeaderBehavior; + } + return dataCollectionResolver.isDataCollectionConfigured() + ? dataCollectionResolver.getHttpRequestHeaders() + : legacyNetworkHeaders(); + } + + @ApiStatus.Internal + public @NotNull KeyValueCollectionBehavior resolveNetworkResponseHeaders( + final @NotNull DataCollectionResolver dataCollectionResolver) { + if (networkResponseHeaderBehavior != null) { + return networkResponseHeaderBehavior; + } + return dataCollectionResolver.isDataCollectionConfigured() + ? dataCollectionResolver.getHttpResponseHeaders() + : legacyNetworkHeaders(); + } + + private static @NotNull KeyValueCollectionBehavior legacyNetworkHeaders() { + return KeyValueCollectionBehavior.allowList(DEFAULT_HEADERS.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 5305e59a321..b4bccb009e8 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -4,6 +4,7 @@ import io.sentry.JsonDeserializer; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.KeyValueCollectionBehavior; import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.ScreenshotStrategyType; @@ -12,6 +13,7 @@ import io.sentry.protocol.SdkVersion; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -66,9 +68,29 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) { if (!replayOptions.getNetworkDetailAllowUrls().isEmpty()) { optionsPayload.put("networkDetailAllowUrls", replayOptions.getNetworkDetailAllowUrls()); - optionsPayload.put("networkRequestHeaders", replayOptions.getNetworkRequestHeaders()); - optionsPayload.put("networkResponseHeaders", replayOptions.getNetworkResponseHeaders()); - optionsPayload.put("networkCaptureBodies", replayOptions.isNetworkCaptureBodies()); + final @NotNull KeyValueCollectionBehavior requestHeaders = + replayOptions.resolveNetworkRequestHeaders(options.getDataCollectionResolver()); + final @NotNull KeyValueCollectionBehavior responseHeaders = + replayOptions.resolveNetworkResponseHeaders(options.getDataCollectionResolver()); + optionsPayload.put( + "networkRequestHeaders", + requestHeaders.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST + ? requestHeaders.getTerms() + : Collections.emptyList()); + optionsPayload.put( + "networkResponseHeaders", + responseHeaders.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST + ? responseHeaders.getTerms() + : Collections.emptyList()); + final @Nullable Boolean replayCaptureBodies = replayOptions.getNetworkCaptureBodies(); + optionsPayload.put( + "networkCaptureBodies", + replayCaptureBodies != null + ? replayCaptureBodies + : replayOptions.isNetworkRequestBodyCaptureEnabled( + options.getDataCollectionResolver()) + && replayOptions.isNetworkResponseBodyCaptureEnabled( + options.getDataCollectionResolver())); if (!replayOptions.getNetworkDetailDenyUrls().isEmpty()) { optionsPayload.put("networkDetailDenyUrls", replayOptions.getNetworkDetailDenyUrls()); diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java index f5134693e00..40905b99975 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -1,11 +1,10 @@ package io.sentry.util.network; -import java.util.HashSet; +import io.sentry.KeyValueCollectionBehavior; +import io.sentry.util.HttpUtils; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.Set; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; @@ -46,7 +45,11 @@ public interface NetworkHeaderExtractor { /** * Creates a ReplayNetworkRequestOrResponse for a request, extracting body and headers based on * configuration. + * + * @deprecated Use the overload accepting a {@link KeyValueCollectionBehavior}. */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public static @NotNull ReplayNetworkRequestOrResponse createRequest( @NotNull final T httpObject, @Nullable final Long bodySize, @@ -54,6 +57,26 @@ public interface NetworkHeaderExtractor { @NotNull final NetworkBodyExtractor bodyExtractor, @NotNull final List networkRequestHeaders, @NotNull final NetworkHeaderExtractor headerExtractor) { + return createRequest( + httpObject, + bodySize, + networkCaptureBodies, + bodyExtractor, + KeyValueCollectionBehavior.allowList(networkRequestHeaders.toArray(new String[0])), + headerExtractor); + } + + /** + * Creates a ReplayNetworkRequestOrResponse for a request, extracting body and headers based on + * configuration. + */ + public static @NotNull ReplayNetworkRequestOrResponse createRequest( + @NotNull final T httpObject, + @Nullable final Long bodySize, + final boolean networkCaptureBodies, + @NotNull final NetworkBodyExtractor bodyExtractor, + @NotNull final KeyValueCollectionBehavior networkRequestHeaders, + @NotNull final NetworkHeaderExtractor headerExtractor) { return createRequestOrResponseInternal( httpObject, @@ -64,6 +87,14 @@ public interface NetworkHeaderExtractor { headerExtractor); } + /** + * Creates a ReplayNetworkRequestOrResponse for a response, extracting body and headers based on + * configuration. + * + * @deprecated Use the overload accepting a {@link KeyValueCollectionBehavior}. + */ + @Deprecated + @SuppressWarnings("InlineMeSuggester") public static @NotNull ReplayNetworkRequestOrResponse createResponse( @NotNull final T httpObject, @Nullable final Long bodySize, @@ -71,6 +102,22 @@ public interface NetworkHeaderExtractor { @NotNull final NetworkBodyExtractor bodyExtractor, @NotNull final List networkResponseHeaders, @NotNull final NetworkHeaderExtractor headerExtractor) { + return createResponse( + httpObject, + bodySize, + networkCaptureBodies, + bodyExtractor, + KeyValueCollectionBehavior.allowList(networkResponseHeaders.toArray(new String[0])), + headerExtractor); + } + + public static @NotNull ReplayNetworkRequestOrResponse createResponse( + @NotNull final T httpObject, + @Nullable final Long bodySize, + final boolean networkCaptureBodies, + @NotNull final NetworkBodyExtractor bodyExtractor, + @NotNull final KeyValueCollectionBehavior networkResponseHeaders, + @NotNull final NetworkHeaderExtractor headerExtractor) { return createRequestOrResponseInternal( httpObject, @@ -122,28 +169,11 @@ private static boolean shouldCaptureUrl( @VisibleForTesting static @NotNull Map getCaptureHeaders( - @Nullable final Map allHeaders, @NotNull final List allowedHeaders) { - - final Map capturedHeaders = new LinkedHashMap<>(); - if (allHeaders == null) { - return capturedHeaders; - } - - // Convert to lowercase for case-insensitive matching - Set normalizedAllowed = new HashSet<>(); - for (String header : allowedHeaders) { - if (header != null) { - normalizedAllowed.add(header.toLowerCase(Locale.ROOT)); - } - } - - for (Map.Entry entry : allHeaders.entrySet()) { - if (normalizedAllowed.contains(entry.getKey().toLowerCase(Locale.ROOT))) { - capturedHeaders.put(entry.getKey(), entry.getValue()); - } - } - - return capturedHeaders; + @Nullable final Map allHeaders, + @NotNull final KeyValueCollectionBehavior behavior) { + return allHeaders == null + ? new LinkedHashMap() + : HttpUtils.filterHeaders(allHeaders, behavior); } private static @NotNull ReplayNetworkRequestOrResponse createRequestOrResponseInternal( @@ -151,7 +181,7 @@ private static boolean shouldCaptureUrl( @Nullable final Long bodySize, final boolean networkCaptureBodies, @NotNull final NetworkBodyExtractor bodyExtractor, - @NotNull final List allowedHeaders, + @NotNull final KeyValueCollectionBehavior headerBehavior, @NotNull final NetworkHeaderExtractor headerExtractor) { NetworkBody body = null; @@ -167,7 +197,7 @@ private static boolean shouldCaptureUrl( } Map headers = - getCaptureHeaders(headerExtractor.extract(httpObject), allowedHeaders); + getCaptureHeaders(headerExtractor.extract(httpObject), headerBehavior); return new ReplayNetworkRequestOrResponse(effectiveBodySize, body, headers); } diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt index 114ef702e43..ce16d0fc8c5 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -4,6 +4,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class SentryReplayOptionsTest { @@ -68,71 +69,168 @@ class SentryReplayOptionsTest { // https://docs.sentry.io/platforms/javascript/session-replay/configuration/#network-details @Test - fun `getNetworkRequestHeaders returns default headers by default`() { + fun `network detail collection overrides default to null`() { val options = SentryReplayOptions(false, null) - assertEquals( - SentryReplayOptions.getNetworkDetailsDefaultHeaders().size, - options.networkRequestHeaders.size, - ) - val headers = options.networkRequestHeaders - SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> - assertEquals(true, headers.contains(defaultHeader)) - } + assertNull(options.networkCaptureBodies) + assertNull(options.networkRequestHeaderBehavior) + assertNull(options.networkResponseHeaderBehavior) } @Test - fun `getNetworkResponseHeaders returns default headers by default`() { + fun `network detail collection overrides accept explicit values`() { val options = SentryReplayOptions(false, null) + val requestBehavior = KeyValueCollectionBehavior.denyList("x-debug") + val responseBehavior = KeyValueCollectionBehavior.off() + + options.networkCaptureBodies = false + options.networkRequestHeaderBehavior = requestBehavior + options.networkResponseHeaderBehavior = responseBehavior + + assertEquals(false, options.networkCaptureBodies) + assertEquals(requestBehavior, options.networkRequestHeaderBehavior) + assertEquals(responseBehavior, options.networkResponseHeaderBehavior) + + options.networkCaptureBodies = null + options.networkRequestHeaderBehavior = null + options.networkResponseHeaderBehavior = null + + assertNull(options.networkCaptureBodies) + assertNull(options.networkRequestHeaderBehavior) + assertNull(options.networkResponseHeaderBehavior) + } + + @Suppress("DEPRECATION") + @Test + fun `legacy network getters preserve defaults when overrides are null`() { + val options = SentryReplayOptions(false, null) + + assertTrue(options.isNetworkCaptureBodies) assertEquals( - SentryReplayOptions.getNetworkDetailsDefaultHeaders().size, - options.networkResponseHeaders.size, + SentryReplayOptions.getNetworkDetailsDefaultHeaders(), + options.networkRequestHeaders, + ) + assertEquals( + SentryReplayOptions.getNetworkDetailsDefaultHeaders(), + options.networkResponseHeaders, ) - - val headers = options.networkResponseHeaders - SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> - assertEquals(true, headers.contains(defaultHeader)) - } } + @Suppress("DEPRECATION") @Test - fun `setNetworkRequestHeaders adds to default headers`() { + fun `legacy header setters create allow list overrides including default headers`() { val options = SentryReplayOptions(false, null) - val additionalHeaders = listOf("X-Custom-Header", "X-Another-Header") - options.setNetworkRequestHeaders(additionalHeaders) + options.setNetworkRequestHeaders(listOf("X-Custom-Header")) + options.setNetworkResponseHeaders(listOf("X-Response-Header")) assertEquals( - SentryReplayOptions.getNetworkDetailsDefaultHeaders().size + additionalHeaders.size, - options.networkRequestHeaders.size, + KeyValueCollectionBehavior.Mode.ALLOW_LIST, + options.networkRequestHeaderBehavior?.mode, ) + assertTrue(options.networkRequestHeaderBehavior!!.terms.contains("Content-Type")) + assertTrue(options.networkRequestHeaderBehavior!!.terms.contains("X-Custom-Header")) + assertEquals( + KeyValueCollectionBehavior.Mode.ALLOW_LIST, + options.networkResponseHeaderBehavior?.mode, + ) + assertTrue(options.networkResponseHeaderBehavior!!.terms.contains("Content-Type")) + assertTrue(options.networkResponseHeaderBehavior!!.terms.contains("X-Response-Header")) + } - val headers = options.networkRequestHeaders - SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> - assertTrue(headers.contains(defaultHeader)) - } - assertTrue(headers.contains("X-Custom-Header")) - assertTrue(headers.contains("X-Another-Header")) + @Suppress("DEPRECATION") + @Test + fun `legacy header setters accept null to restore inheritance`() { + val options = SentryReplayOptions(false, null) + options.setNetworkRequestHeaders(listOf("X-Custom-Header")) + options.setNetworkResponseHeaders(listOf("X-Response-Header")) + + options.setNetworkRequestHeaders(null) + options.setNetworkResponseHeaders(null) + + assertNull(options.networkRequestHeaderBehavior) + assertNull(options.networkResponseHeaderBehavior) } + @Suppress("DEPRECATION") @Test - fun `setNetworkResponseHeaders adds to default headers`() { + fun `legacy header getters return empty lists for non allow list behavior`() { val options = SentryReplayOptions(false, null) - val additionalHeaders = listOf("X-Response-Header", "X-Debug-Header") - options.setNetworkResponseHeaders(additionalHeaders) + options.networkRequestHeaderBehavior = KeyValueCollectionBehavior.denyList("x-debug") + options.networkResponseHeaderBehavior = KeyValueCollectionBehavior.off() + assertTrue(options.networkRequestHeaders.isEmpty()) + assertTrue(options.networkResponseHeaders.isEmpty()) + } + + @Test + fun `resolved network options use legacy defaults when data collection is absent`() { + val options = SentryOptions() + val replay = options.sessionReplay + val defaultHeaders = + KeyValueCollectionBehavior.allowList( + *SentryReplayOptions.getNetworkDetailsDefaultHeaders().toTypedArray() + ) + + assertTrue(replay.isNetworkRequestBodyCaptureEnabled(options.dataCollectionResolver)) + assertTrue(replay.isNetworkResponseBodyCaptureEnabled(options.dataCollectionResolver)) + assertEquals( + defaultHeaders, + replay.resolveNetworkRequestHeaders(options.dataCollectionResolver), + ) assertEquals( - SentryReplayOptions.getNetworkDetailsDefaultHeaders().size + additionalHeaders.size, - options.networkResponseHeaders.size, + defaultHeaders, + replay.resolveNetworkResponseHeaders(options.dataCollectionResolver), ) + } - val headers = options.networkResponseHeaders - SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> - assertTrue(headers.contains(defaultHeader)) - } - assertTrue(headers.contains("X-Response-Header")) - assertTrue(headers.contains("X-Debug-Header")) + @Test + fun `resolved network options fall back to data collection when configured`() { + val options = + SentryOptions().apply { + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_RESPONSE) + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("x-debug") + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + } + val replay = options.sessionReplay + + assertFalse(replay.isNetworkRequestBodyCaptureEnabled(options.dataCollectionResolver)) + assertTrue(replay.isNetworkResponseBodyCaptureEnabled(options.dataCollectionResolver)) + assertEquals( + KeyValueCollectionBehavior.denyList("x-debug"), + replay.resolveNetworkRequestHeaders(options.dataCollectionResolver), + ) + assertEquals( + KeyValueCollectionBehavior.off(), + replay.resolveNetworkResponseHeaders(options.dataCollectionResolver), + ) + } + + @Test + fun `explicit Replay network options take precedence over data collection`() { + val options = + SentryOptions().apply { + dataCollection.httpBodies = emptySet() + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + sessionReplay.networkCaptureBodies = true + sessionReplay.networkRequestHeaderBehavior = + KeyValueCollectionBehavior.allowList("x-request-id") + sessionReplay.networkResponseHeaderBehavior = KeyValueCollectionBehavior.denyList("x-debug") + } + val replay = options.sessionReplay + + assertTrue(replay.isNetworkRequestBodyCaptureEnabled(options.dataCollectionResolver)) + assertTrue(replay.isNetworkResponseBodyCaptureEnabled(options.dataCollectionResolver)) + assertEquals( + KeyValueCollectionBehavior.allowList("x-request-id"), + replay.resolveNetworkRequestHeaders(options.dataCollectionResolver), + ) + assertEquals( + KeyValueCollectionBehavior.denyList("x-debug"), + replay.resolveNetworkResponseHeaders(options.dataCollectionResolver), + ) } // Custom Masking Integration Tests diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt index 32dbd9a7d47..e023f37fe89 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt @@ -1,6 +1,8 @@ package io.sentry.rrweb +import io.sentry.HttpBodyType import io.sentry.ILogger +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryOptions import io.sentry.SentryReplayOptions import io.sentry.SentryReplayOptions.SentryReplayQuality.LOW @@ -104,6 +106,22 @@ class RRWebOptionsEventSerializationTest { ) } + @Test + fun `data collection network details are included when Replay options inherit`() { + val options = + SentryOptions().apply { + sessionReplay.setNetworkDetailAllowUrls(listOf("https://api.example.com/*")) + dataCollection.httpBodies = emptySet() + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("x-debug") + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + } + val payload = RRWebOptionsEvent(options).optionsPayload + + assertEquals(emptyList(), payload["networkRequestHeaders"]) + assertEquals(emptyList(), payload["networkResponseHeaders"]) + assertEquals(false, payload["networkCaptureBodies"]) + } + @Test fun `networkDetailDenyUrls are included when networkDetailAllowUrls is configured`() { val options = diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt index 25b142af7e9..6df55961bc8 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt @@ -1,6 +1,7 @@ package io.sentry.util.network import io.sentry.ILogger +import io.sentry.KeyValueCollectionBehavior import java.util.LinkedHashMap import kotlin.test.assertEquals import kotlin.test.assertNull @@ -23,7 +24,7 @@ class NetworkDetailCaptureUtilsTest { { bytes -> NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) }, - emptyList(), + KeyValueCollectionBehavior.off(), { emptyMap() }, ) @@ -43,7 +44,7 @@ class NetworkDetailCaptureUtilsTest { { bytes -> NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) }, - emptyList(), + KeyValueCollectionBehavior.off(), { emptyMap() }, ) @@ -58,7 +59,7 @@ class NetworkDetailCaptureUtilsTest { null, false, { null }, - emptyList(), + KeyValueCollectionBehavior.off(), { emptyMap() }, ) @@ -66,8 +67,7 @@ class NetworkDetailCaptureUtilsTest { } @Test - fun `getCaptureHeaders should match headers case-insensitively`() { - // Setup: allHeaders with mixed case keys + fun `getCaptureHeaders matches allow list case-insensitively and filters sensitive values`() { val allHeaders = LinkedHashMap().apply { put("Content-Type", "application/json") @@ -75,22 +75,21 @@ class NetworkDetailCaptureUtilsTest { put("X-Custom-Header", "custom-value") put("accept", "application/json") } + val behavior = + KeyValueCollectionBehavior.allowList( + "content-type", + "AUTHORIZATION", + "x-custom-header", + "ACCEPT", + ) - // Test: allowedHeaders with different casing - val allowedHeaders = listOf("content-type", "AUTHORIZATION", "x-custom-header", "ACCEPT") - - val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, behavior) - // All headers should be matched despite case differences assertEquals(4, result.size) - - // Original casing should be preserved in output assertEquals("application/json", result["Content-Type"]) - assertEquals("Bearer token123", result["Authorization"]) + assertEquals("[Filtered]", result["Authorization"]) assertEquals("custom-value", result["X-Custom-Header"]) assertEquals("application/json", result["accept"]) - - // Verify keys maintain original casing from allHeaders assertTrue(result.containsKey("Content-Type")) assertTrue(result.containsKey("Authorization")) assertTrue(result.containsKey("X-Custom-Header")) @@ -98,65 +97,52 @@ class NetworkDetailCaptureUtilsTest { } @Test - fun `getCaptureHeaders should handle null allHeaders`() { - val allowedHeaders = listOf("content-type") - - val result = NetworkDetailCaptureUtils.getCaptureHeaders(null, allowedHeaders) + fun `getCaptureHeaders handles null allHeaders`() { + val result = + NetworkDetailCaptureUtils.getCaptureHeaders( + null, + KeyValueCollectionBehavior.allowList("content-type"), + ) assertTrue(result.isEmpty()) } @Test - fun `getCaptureHeaders should handle empty allowedHeaders`() { - val allHeaders = mapOf("Content-Type" to "application/json") - val allowedHeaders = emptyList() - - val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + fun `getCaptureHeaders filters every value for empty allow list`() { + val result = + NetworkDetailCaptureUtils.getCaptureHeaders( + mapOf("Content-Type" to "application/json"), + KeyValueCollectionBehavior.allowList(), + ) - assertTrue(result.isEmpty()) + assertEquals(mapOf("Content-Type" to "[Filtered]"), result) } @Test - fun `getCaptureHeaders should only capture allowed headers`() { - val allHeaders = - mapOf( - "Content-Type" to "application/json", - "Authorization" to "Bearer token123", - "X-Unwanted-Header" to "should-not-appear", + fun `getCaptureHeaders applies deny list`() { + val result = + NetworkDetailCaptureUtils.getCaptureHeaders( + mapOf( + "Content-Type" to "application/json", + "X-Debug" to "secret", + "X-Request-Id" to "123", + ), + KeyValueCollectionBehavior.denyList("debug"), ) - val allowedHeaders = listOf("content-type", "authorization") - - val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) - - assertEquals(2, result.size) assertEquals("application/json", result["Content-Type"]) - assertEquals("Bearer token123", result["Authorization"]) - - // Unwanted header should not be present - assertTrue(!result.containsKey("X-Unwanted-Header")) + assertEquals("[Filtered]", result["X-Debug"]) + assertEquals("123", result["X-Request-Id"]) } @Test - fun `getCaptureHeaders should handle null elements in allowedHeaders`() { - val allHeaders = - mapOf( - "Content-Type" to "application/json", - "Authorization" to "Bearer token123", - "X-Custom-Header" to "custom-value", + fun `getCaptureHeaders applies off mode`() { + val result = + NetworkDetailCaptureUtils.getCaptureHeaders( + mapOf("Content-Type" to "application/json"), + KeyValueCollectionBehavior.off(), ) - // allowedHeaders contains null elements which should be ignored - val allowedHeaders = listOf(null, "content-type", null, "authorization", null) - - val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) - - // Only non-null allowed headers should be matched - assertEquals(2, result.size) - assertEquals("application/json", result["Content-Type"]) - assertEquals("Bearer token123", result["Authorization"]) - - // X-Custom-Header should not be present as it's not in the allowed list - assertTrue(!result.containsKey("X-Custom-Header")) + assertTrue(result.isEmpty()) } }