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()) } }