From dd4bdfc222cd86f8022e77a3aa1ce380e63a31e8 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 14 Jul 2026 13:58:32 +0200 Subject: [PATCH 01/63] collection: Data Collection From e38fdbd7fefa768b7151edfe87ed8e49775aa52e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 14 Jul 2026 14:00:19 +0200 Subject: [PATCH 02/63] feat(core): Add Data Collection configuration types Add the public Data Collection model, key-value collection behavior, and HTTP body direction types. Preserve unset values internally so the resolver can distinguish legacy bridge mode from explicit configuration. Refs #5666 Co-Authored-By: Claude --- sentry/api/sentry.api | 61 +++++++ .../main/java/io/sentry/DataCollection.java | 156 ++++++++++++++++++ .../src/main/java/io/sentry/HttpBodyType.java | 13 ++ .../io/sentry/KeyValueCollectionBehavior.java | 76 +++++++++ .../test/java/io/sentry/DataCollectionTest.kt | 115 +++++++++++++ .../sentry/KeyValueCollectionBehaviorTest.kt | 49 ++++++ 6 files changed, 470 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/DataCollection.java create mode 100644 sentry/src/main/java/io/sentry/HttpBodyType.java create mode 100644 sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java create mode 100644 sentry/src/test/java/io/sentry/DataCollectionTest.kt create mode 100644 sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 00183bc9b30..22c4636acff 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -383,6 +383,40 @@ public final class io/sentry/DataCategory : java/lang/Enum { public static fun values ()[Lio/sentry/DataCategory; } +public final class io/sentry/DataCollection { + public fun ()V + public fun getCookies ()Lio/sentry/KeyValueCollectionBehavior; + public fun getDatabaseQueryData ()Ljava/lang/Boolean; + public fun getGraphql ()Lio/sentry/DataCollection$Graphql; + public fun getHttpBodies ()Ljava/util/Set; + public fun getHttpHeaders ()Lio/sentry/DataCollection$HttpHeaders; + public fun getQueryParams ()Lio/sentry/KeyValueCollectionBehavior; + public fun getQueues ()Ljava/lang/Boolean; + public fun getUserInfo ()Ljava/lang/Boolean; + public fun setCookies (Lio/sentry/KeyValueCollectionBehavior;)V + public fun setDatabaseQueryData (Z)V + public fun setHttpBodies (Ljava/util/Set;)V + public fun setQueryParams (Lio/sentry/KeyValueCollectionBehavior;)V + public fun setQueues (Z)V + public fun setUserInfo (Z)V +} + +public final class io/sentry/DataCollection$Graphql { + public fun ()V + public fun getDocument ()Ljava/lang/Boolean; + public fun getVariables ()Ljava/lang/Boolean; + public fun setDocument (Z)V + public fun setVariables (Z)V +} + +public final class io/sentry/DataCollection$HttpHeaders { + public fun ()V + public fun getRequest ()Lio/sentry/KeyValueCollectionBehavior; + public fun getResponse ()Lio/sentry/KeyValueCollectionBehavior; + public fun setRequest (Lio/sentry/KeyValueCollectionBehavior;)V + public fun setResponse (Lio/sentry/KeyValueCollectionBehavior;)V +} + public final class io/sentry/DateUtils { public static fun dateToSeconds (Ljava/util/Date;)D public static fun doubleToBigDecimal (D)Ljava/math/BigDecimal; @@ -635,6 +669,15 @@ public final class io/sentry/HostnameCache { public static fun getInstance ()Lio/sentry/HostnameCache; } +public final class io/sentry/HttpBodyType : java/lang/Enum { + public static final field INCOMING_REQUEST Lio/sentry/HttpBodyType; + public static final field INCOMING_RESPONSE Lio/sentry/HttpBodyType; + public static final field OUTGOING_REQUEST Lio/sentry/HttpBodyType; + public static final field OUTGOING_RESPONSE Lio/sentry/HttpBodyType; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/HttpBodyType; + public static fun values ()[Lio/sentry/HttpBodyType; +} + public final class io/sentry/HttpStatusCodeRange { public static final field DEFAULT_MAX I public static final field DEFAULT_MIN I @@ -1381,6 +1424,24 @@ public abstract interface class io/sentry/JsonUnknown { public abstract fun setUnknown (Ljava/util/Map;)V } +public final class io/sentry/KeyValueCollectionBehavior { + public static fun allowList ([Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior; + public static fun denyList ([Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior; + public fun equals (Ljava/lang/Object;)Z + public fun getMode ()Lio/sentry/KeyValueCollectionBehavior$Mode; + public fun getTerms ()Ljava/util/List; + public fun hashCode ()I + public static fun off ()Lio/sentry/KeyValueCollectionBehavior; +} + +public final class io/sentry/KeyValueCollectionBehavior$Mode : java/lang/Enum { + public static final field ALLOW_LIST Lio/sentry/KeyValueCollectionBehavior$Mode; + public static final field DENY_LIST Lio/sentry/KeyValueCollectionBehavior$Mode; + public static final field OFF Lio/sentry/KeyValueCollectionBehavior$Mode; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior$Mode; + public static fun values ()[Lio/sentry/KeyValueCollectionBehavior$Mode; +} + public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor, java/io/Closeable { public fun (Lio/sentry/SentryOptions;)V public fun close ()V diff --git a/sentry/src/main/java/io/sentry/DataCollection.java b/sentry/src/main/java/io/sentry/DataCollection.java new file mode 100644 index 00000000000..c1882938068 --- /dev/null +++ b/sentry/src/main/java/io/sentry/DataCollection.java @@ -0,0 +1,156 @@ +package io.sentry; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Configures data that the SDK collects automatically. */ +public final class DataCollection { + + private boolean overridden; + private @Nullable Boolean userInfo; + private @Nullable KeyValueCollectionBehavior cookies; + private @Nullable KeyValueCollectionBehavior queryParams; + private @Nullable Set httpBodies; + private @Nullable Boolean databaseQueryData; + private @Nullable Boolean queues; + private final @NotNull HttpHeaders httpHeaders = new HttpHeaders(); + private final @NotNull Graphql graphql = new Graphql(); + + public DataCollection() { + this(true); + } + + DataCollection(final boolean overridden) { + this.overridden = overridden; + } + + public @Nullable Boolean getUserInfo() { + return userInfo; + } + + public void setUserInfo(final boolean userInfo) { + this.userInfo = userInfo; + } + + public @Nullable KeyValueCollectionBehavior getCookies() { + return cookies; + } + + public void setCookies(final @Nullable KeyValueCollectionBehavior cookies) { + this.cookies = cookies; + } + + public @Nullable KeyValueCollectionBehavior getQueryParams() { + return queryParams; + } + + public void setQueryParams(final @Nullable KeyValueCollectionBehavior queryParams) { + this.queryParams = queryParams; + } + + public @Nullable Set getHttpBodies() { + return httpBodies; + } + + public void setHttpBodies(final @Nullable Set httpBodies) { + this.httpBodies = + httpBodies == null + ? null + : httpBodies.isEmpty() + ? Collections.emptySet() + : Collections.unmodifiableSet(EnumSet.copyOf(httpBodies)); + } + + public @Nullable Boolean getDatabaseQueryData() { + return databaseQueryData; + } + + public void setDatabaseQueryData(final boolean databaseQueryData) { + this.databaseQueryData = databaseQueryData; + } + + public @Nullable Boolean getQueues() { + return queues; + } + + public void setQueues(final boolean queues) { + this.queues = queues; + } + + public @NotNull HttpHeaders getHttpHeaders() { + return httpHeaders; + } + + public @NotNull Graphql getGraphql() { + return graphql; + } + + @ApiStatus.Internal + boolean isExplicitlyConfigured() { + return overridden + || userInfo != null + || cookies != null + || queryParams != null + || httpBodies != null + || databaseQueryData != null + || queues != null + || httpHeaders.hasOverrides() + || graphql.hasOverrides(); + } + + /** Configures collection of request and response HTTP headers. */ + public static final class HttpHeaders { + private @Nullable KeyValueCollectionBehavior request; + private @Nullable KeyValueCollectionBehavior response; + + public @Nullable KeyValueCollectionBehavior getRequest() { + return request; + } + + public void setRequest(final @Nullable KeyValueCollectionBehavior request) { + this.request = request; + } + + public @Nullable KeyValueCollectionBehavior getResponse() { + return response; + } + + public void setResponse(final @Nullable KeyValueCollectionBehavior response) { + this.response = response; + } + + private boolean hasOverrides() { + return request != null || response != null; + } + } + + /** Configures collection of GraphQL document and variable content. */ + public static final class Graphql { + private @Nullable Boolean document; + private @Nullable Boolean variables; + + public @Nullable Boolean getDocument() { + return document; + } + + public void setDocument(final boolean document) { + this.document = document; + } + + public @Nullable Boolean getVariables() { + return variables; + } + + public void setVariables(final boolean variables) { + this.variables = variables; + } + + private boolean hasOverrides() { + return document != null || variables != null; + } + } +} diff --git a/sentry/src/main/java/io/sentry/HttpBodyType.java b/sentry/src/main/java/io/sentry/HttpBodyType.java new file mode 100644 index 00000000000..9b1b9a24b50 --- /dev/null +++ b/sentry/src/main/java/io/sentry/HttpBodyType.java @@ -0,0 +1,13 @@ +package io.sentry; + +/** A direction of automatically collected HTTP body content. */ +public enum HttpBodyType { + /** A request received by a server integration. */ + INCOMING_REQUEST, + /** A request sent by a client integration. */ + OUTGOING_REQUEST, + /** A response received by a client integration. */ + INCOMING_RESPONSE, + /** A response sent by a server integration. */ + OUTGOING_RESPONSE +} diff --git a/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java b/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java new file mode 100644 index 00000000000..d9daeb9bc02 --- /dev/null +++ b/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java @@ -0,0 +1,76 @@ +package io.sentry; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +/** Controls how automatically collected key-value data is filtered. */ +public final class KeyValueCollectionBehavior { + + /** The collection strategy applied to key-value data. */ + public enum Mode { + /** Do not collect keys or values. */ + OFF, + /** Collect keys and filter values whose keys match a deny-list term. */ + DENY_LIST, + /** Collect keys and filter values unless their keys match an allow-list term. */ + ALLOW_LIST + } + + private final @NotNull Mode mode; + private final @NotNull List terms; + + private KeyValueCollectionBehavior(final @NotNull Mode mode, final @NotNull List terms) { + this.mode = mode; + this.terms = Collections.unmodifiableList(new ArrayList<>(terms)); + } + + /** Disables collection of the category. */ + public static @NotNull KeyValueCollectionBehavior off() { + return new KeyValueCollectionBehavior(Mode.OFF, Collections.emptyList()); + } + + /** + * Collects the category and filters values whose keys match the built-in sensitive deny-list or + * one of {@code terms}. + */ + public static @NotNull KeyValueCollectionBehavior denyList(final @NotNull String... terms) { + return new KeyValueCollectionBehavior(Mode.DENY_LIST, Arrays.asList(terms)); + } + + /** + * Collects the category and only includes plaintext values whose keys match one of {@code terms}. + * Values matching the built-in sensitive deny-list are still filtered. + */ + public static @NotNull KeyValueCollectionBehavior allowList(final @NotNull String... terms) { + return new KeyValueCollectionBehavior(Mode.ALLOW_LIST, Arrays.asList(terms)); + } + + public @NotNull Mode getMode() { + return mode; + } + + public @NotNull List getTerms() { + return terms; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + final KeyValueCollectionBehavior that = (KeyValueCollectionBehavior) other; + return mode == that.mode && terms.equals(that.terms); + } + + @Override + public int hashCode() { + return Objects.hash(mode, terms); + } +} diff --git a/sentry/src/test/java/io/sentry/DataCollectionTest.kt b/sentry/src/test/java/io/sentry/DataCollectionTest.kt new file mode 100644 index 00000000000..594df7bc9d8 --- /dev/null +++ b/sentry/src/test/java/io/sentry/DataCollectionTest.kt @@ -0,0 +1,115 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class DataCollectionTest { + @Test + fun `public constructor creates explicit empty configuration`() { + val dataCollection = DataCollection() + + assertThat(dataCollection.userInfo).isNull() + assertThat(dataCollection.cookies).isNull() + assertThat(dataCollection.queryParams).isNull() + assertThat(dataCollection.httpBodies).isNull() + assertThat(dataCollection.databaseQueryData).isNull() + assertThat(dataCollection.queues).isNull() + assertThat(dataCollection.httpHeaders.request).isNull() + assertThat(dataCollection.httpHeaders.response).isNull() + assertThat(dataCollection.graphql.document).isNull() + assertThat(dataCollection.graphql.variables).isNull() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `SDK-owned configuration starts unconfigured`() { + val dataCollection = DataCollection(false) + + assertThat(dataCollection.isExplicitlyConfigured()).isFalse() + } + + @Test + fun `nested override makes SDK-owned configuration explicit`() { + val dataCollection = DataCollection(false) + + dataCollection.graphql.setVariables(false) + + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `explicit false is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setUserInfo(false) + + assertThat(dataCollection.userInfo).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `empty HTTP body set is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setHttpBodies(emptySet()) + + assertThat(dataCollection.httpBodies).isEmpty() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `HTTP body set is copied and immutable`() { + val bodies = mutableSetOf(HttpBodyType.INCOMING_REQUEST) + val dataCollection = DataCollection() + + dataCollection.setHttpBodies(bodies) + bodies += HttpBodyType.OUTGOING_REQUEST + + assertThat(dataCollection.httpBodies).containsExactly(HttpBodyType.INCOMING_REQUEST) + assertFailsWith { + dataCollection.httpBodies!!.add(HttpBodyType.OUTGOING_REQUEST) + } + } + + @Test + fun `database query data false is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setDatabaseQueryData(false) + + assertThat(dataCollection.databaseQueryData).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `queues false is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setQueues(false) + + assertThat(dataCollection.queues).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `nested HTTP header override marks configuration explicit`() { + val dataCollection = DataCollection(false) + val behavior = KeyValueCollectionBehavior.denyList("authorization") + + dataCollection.httpHeaders.setRequest(behavior) + + assertThat(dataCollection.httpHeaders.request).isSameInstanceAs(behavior) + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `nested GraphQL false marks configuration explicit`() { + val dataCollection = DataCollection(false) + + dataCollection.graphql.setVariables(false) + + assertThat(dataCollection.graphql.variables).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } +} diff --git a/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt b/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt new file mode 100644 index 00000000000..7e014eec504 --- /dev/null +++ b/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt @@ -0,0 +1,49 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test + +class KeyValueCollectionBehaviorTest { + @Test + fun `off has no terms`() { + val behavior = KeyValueCollectionBehavior.off() + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) + assertThat(behavior.terms).isEmpty() + } + + @Test + fun `deny list stores terms in order`() { + val behavior = KeyValueCollectionBehavior.denyList("token", "session") + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(behavior.terms).containsExactly("token", "session").inOrder() + } + + @Test + fun `allow list can be empty`() { + val behavior = KeyValueCollectionBehavior.allowList() + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(behavior.terms).isEmpty() + } + + @Test + fun `terms are copied and immutable`() { + val terms = arrayOf("token") + val behavior = KeyValueCollectionBehavior.denyList(*terms) + + terms[0] = "password" + + assertThat(behavior.terms).containsExactly("token") + } + + @Test + fun `equal behaviors have equal hash codes`() { + val first = KeyValueCollectionBehavior.allowList("language", "theme") + val second = KeyValueCollectionBehavior.allowList("language", "theme") + + assertThat(first).isEqualTo(second) + assertThat(first.hashCode()).isEqualTo(second.hashCode()) + } +} From c4ea3db5f94f476a85edc92bb6d9a15e3b638180 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 14 Jul 2026 14:38:08 +0200 Subject: [PATCH 03/63] test(core): Remove Data Collection identity assertion Avoid coupling the Data Collection configuration test to reference identity. The test only needs to verify that setting a nested header behavior marks the configuration explicit. Refs #5666 Co-Authored-By: Claude --- sentry/src/test/java/io/sentry/DataCollectionTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry/src/test/java/io/sentry/DataCollectionTest.kt b/sentry/src/test/java/io/sentry/DataCollectionTest.kt index 594df7bc9d8..8bc9c7af7ae 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionTest.kt @@ -99,7 +99,6 @@ class DataCollectionTest { dataCollection.httpHeaders.setRequest(behavior) - assertThat(dataCollection.httpHeaders.request).isSameInstanceAs(behavior) assertThat(dataCollection.isExplicitlyConfigured()).isTrue() } From a337d9e7d2279888eb16e6741df53f1fd75103e1 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 14 Jul 2026 14:39:44 +0200 Subject: [PATCH 04/63] feat(core): Expose Data Collection options Add an always-present DataCollection object to SentryOptions while preserving an unconfigured state for the legacy bridge. Expose public getter and setter APIs and cover explicit-empty and nested override behavior. Refs #5666 Co-Authored-By: Claude --- sentry/api/sentry.api | 2 + .../main/java/io/sentry/SentryOptions.java | 21 +++++++++ .../test/java/io/sentry/SentryOptionsTest.kt | 47 +++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 22c4636acff..2e512df624f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3697,6 +3697,7 @@ public class io/sentry/SentryOptions { public fun getContextTags ()Ljava/util/List; public fun getContinuousProfiler ()Lio/sentry/IContinuousProfiler; public fun getCron ()Lio/sentry/SentryOptions$Cron; + public fun getDataCollection ()Lio/sentry/DataCollection; public fun getDateProvider ()Lio/sentry/SentryDateProvider; public fun getDeadlineTimeout ()J public fun getDebugMetaLoader ()Lio/sentry/internal/debugmeta/IDebugMetaLoader; @@ -3845,6 +3846,7 @@ public class io/sentry/SentryOptions { public fun setConnectionTimeoutMillis (I)V public fun setContinuousProfiler (Lio/sentry/IContinuousProfiler;)V public fun setCron (Lio/sentry/SentryOptions$Cron;)V + public fun setDataCollection (Lio/sentry/DataCollection;)V public fun setDateProvider (Lio/sentry/SentryDateProvider;)V public fun setDeadlineTimeout (J)V public fun setDebug (Z)V diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 3c55f5e1cfa..bdafb889c2d 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -338,6 +338,8 @@ public class SentryOptions { /** whether to send personal identifiable information along with events */ private boolean sendDefaultPii = false; + private @NotNull DataCollection dataCollection = new DataCollection(false); + /** SSLSocketFactory for self-signed certificate trust * */ private @Nullable SSLSocketFactory sslSocketFactory; @@ -1697,6 +1699,25 @@ public void setSendDefaultPii(boolean sendDefaultPii) { this.sendDefaultPii = sendDefaultPii; } + /** + * Returns the configuration for data that the SDK collects automatically. + * + *

The returned object is always present. Accessing it does not configure data collection, but + * setting one of its options does. + */ + public @NotNull DataCollection getDataCollection() { + return dataCollection; + } + + /** + * Replaces the configuration for data that the SDK collects automatically. + * + *

Passing an empty {@link DataCollection} opts into the documented data-collection defaults. + */ + public void setDataCollection(final @NotNull DataCollection dataCollection) { + this.dataCollection = dataCollection; + } + /** * Adds a Scope observer * diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 9402c6fee9b..3a43481cd03 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.SentryOptions.RequestSize import io.sentry.logger.ILoggerBatchProcessorFactory import io.sentry.util.StringUtils @@ -20,6 +21,52 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify class SentryOptionsTest { + @Test + fun `data collection is always present without being explicitly configured`() { + val options = SentryOptions() + + assertThat(options.dataCollection).isNotNull() + assertThat(options.dataCollection.isExplicitlyConfigured()).isFalse() + } + + @Test + fun `data collection getter returns the same instance`() { + val options = SentryOptions() + + assertThat(options.dataCollection).isSameInstanceAs(options.dataCollection) + assertThat(options.dataCollection.isExplicitlyConfigured()).isFalse() + } + + @Test + fun `setting a data collection override marks it explicitly configured`() { + val options = SentryOptions() + + options.dataCollection.setUserInfo(false) + + assertThat(options.dataCollection.userInfo).isFalse() + assertThat(options.dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `setting an empty data collection marks it explicitly configured`() { + val options = SentryOptions() + + options.dataCollection = DataCollection() + + assertThat(options.dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `setting data collection replaces the default instance`() { + val options = SentryOptions() + val dataCollection = DataCollection().apply { setQueues(false) } + + options.dataCollection = dataCollection + + assertThat(options.dataCollection).isSameInstanceAs(dataCollection) + assertThat(options.dataCollection.queues).isFalse() + } + @Test fun `when options is initialized, logger is not null`() { assertNotNull(SentryOptions().logger) From 1ad9a194f7a49ca75309ae1a484a9289f85e1bdf Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 14 Jul 2026 17:47:23 +0200 Subject: [PATCH 05/63] feat(core): Add Data Collection resolver Add a resolver owned by SentryOptions that applies namespace-wide Data Collection defaults and legacy sendDefaultPii fallbacks. Expose boolean, key-value, and directional HTTP body policies without changing production collection paths. Refs #5666 Co-Authored-By: Claude --- sentry/api/sentry.api | 17 ++ .../io/sentry/DataCollectionResolver.java | 105 ++++++++ .../main/java/io/sentry/SentryOptions.java | 9 + .../io/sentry/DataCollectionResolverTest.kt | 231 ++++++++++++++++++ 4 files changed, 362 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/DataCollectionResolver.java create mode 100644 sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2e512df624f..bfdc8ff97fe 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -417,6 +417,22 @@ public final class io/sentry/DataCollection$HttpHeaders { public fun setResponse (Lio/sentry/KeyValueCollectionBehavior;)V } +public final class io/sentry/DataCollectionResolver { + public fun getCookies ()Lio/sentry/KeyValueCollectionBehavior; + public fun getHttpRequestHeaders ()Lio/sentry/KeyValueCollectionBehavior; + public fun getHttpResponseHeaders ()Lio/sentry/KeyValueCollectionBehavior; + public fun getQueryParams ()Lio/sentry/KeyValueCollectionBehavior; + public fun isDataCollectionConfigured ()Z + public fun isDatabaseQueryData ()Z + public fun isGraphqlDocument ()Z + public fun isGraphqlVariables ()Z + public fun isIncomingRequestBody ()Z + public fun isIncomingResponseBody ()Z + public fun isOutgoingRequestBody ()Z + public fun isOutgoingResponseBody ()Z + public fun isUserInfo ()Z +} + public final class io/sentry/DateUtils { public static fun dateToSeconds (Ljava/util/Date;)D public static fun doubleToBigDecimal (D)Ljava/math/BigDecimal; @@ -3698,6 +3714,7 @@ public class io/sentry/SentryOptions { public fun getContinuousProfiler ()Lio/sentry/IContinuousProfiler; public fun getCron ()Lio/sentry/SentryOptions$Cron; public fun getDataCollection ()Lio/sentry/DataCollection; + public fun getDataCollectionResolver ()Lio/sentry/DataCollectionResolver; public fun getDateProvider ()Lio/sentry/SentryDateProvider; public fun getDeadlineTimeout ()J public fun getDebugMetaLoader ()Lio/sentry/internal/debugmeta/IDebugMetaLoader; diff --git a/sentry/src/main/java/io/sentry/DataCollectionResolver.java b/sentry/src/main/java/io/sentry/DataCollectionResolver.java new file mode 100644 index 00000000000..3063268f0f7 --- /dev/null +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -0,0 +1,105 @@ +package io.sentry; + +import java.util.Set; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Resolves effective Data Collection policies for SDK integrations. */ +@ApiStatus.Internal +public final class DataCollectionResolver { + + private static final @NotNull KeyValueCollectionBehavior OFF = KeyValueCollectionBehavior.off(); + private static final @NotNull KeyValueCollectionBehavior EMPTY_DENY_LIST = + KeyValueCollectionBehavior.denyList(); + + private final @NotNull SentryOptions options; + + DataCollectionResolver(final @NotNull SentryOptions options) { + this.options = options; + } + + public boolean isDataCollectionConfigured() { + return options.getDataCollection().isExplicitlyConfigured(); + } + + public boolean isUserInfo() { + return explicitOrSendDefaultPii(options.getDataCollection().getUserInfo(), true); + } + + public boolean isDatabaseQueryData() { + return explicitOrSendDefaultPii(options.getDataCollection().getDatabaseQueryData(), true); + } + + public boolean isGraphqlDocument() { + return explicitOrSendDefaultPii(options.getDataCollection().getGraphql().getDocument(), true); + } + + public boolean isGraphqlVariables() { + return explicitOrSendDefaultPii(options.getDataCollection().getGraphql().getVariables(), true); + } + + public @NotNull KeyValueCollectionBehavior getCookies() { + final @NotNull DataCollection dataCollection = options.getDataCollection(); + final @Nullable KeyValueCollectionBehavior cookies = dataCollection.getCookies(); + + if (cookies != null) { + return cookies; + } + if (isDataCollectionConfigured()) { + return EMPTY_DENY_LIST; + } + return options.isSendDefaultPii() ? EMPTY_DENY_LIST : OFF; + } + + public @NotNull KeyValueCollectionBehavior getQueryParams() { + return explicitOrEmptyDenyList(options.getDataCollection().getQueryParams()); + } + + public @NotNull KeyValueCollectionBehavior getHttpRequestHeaders() { + return explicitOrEmptyDenyList(options.getDataCollection().getHttpHeaders().getRequest()); + } + + public @NotNull KeyValueCollectionBehavior getHttpResponseHeaders() { + return explicitOrEmptyDenyList(options.getDataCollection().getHttpHeaders().getResponse()); + } + + public boolean isIncomingRequestBody() { + return isHttpBodyEnabled(HttpBodyType.INCOMING_REQUEST, options.isSendDefaultPii()); + } + + public boolean isOutgoingRequestBody() { + return isHttpBodyEnabled(HttpBodyType.OUTGOING_REQUEST, true); + } + + public boolean isIncomingResponseBody() { + return isHttpBodyEnabled(HttpBodyType.INCOMING_RESPONSE, true); + } + + public boolean isOutgoingResponseBody() { + return isHttpBodyEnabled(HttpBodyType.OUTGOING_RESPONSE, options.isSendDefaultPii()); + } + + private boolean explicitOrSendDefaultPii( + final @Nullable Boolean explicit, final boolean defaultValue) { + if (explicit != null) { + return explicit; + } + return isDataCollectionConfigured() ? defaultValue : options.isSendDefaultPii(); + } + + private @NotNull KeyValueCollectionBehavior explicitOrEmptyDenyList( + final @Nullable KeyValueCollectionBehavior explicit) { + return explicit != null ? explicit : EMPTY_DENY_LIST; + } + + private boolean isHttpBodyEnabled( + final @NotNull HttpBodyType bodyType, final boolean legacyFallback) { + final @NotNull DataCollection dataCollection = options.getDataCollection(); + final @Nullable Set httpBodies = dataCollection.getHttpBodies(); + if (httpBodies != null) { + return httpBodies.contains(bodyType); + } + return isDataCollectionConfigured() || legacyFallback; + } +} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index bdafb889c2d..cbd007b7434 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -340,6 +340,9 @@ public class SentryOptions { private @NotNull DataCollection dataCollection = new DataCollection(false); + private final @NotNull DataCollectionResolver dataCollectionResolver = + new DataCollectionResolver(this); + /** SSLSocketFactory for self-signed certificate trust * */ private @Nullable SSLSocketFactory sslSocketFactory; @@ -1718,6 +1721,12 @@ public void setDataCollection(final @NotNull DataCollection dataCollection) { this.dataCollection = dataCollection; } + /** Returns the Data Collection policy resolver used by SDK integrations. */ + @ApiStatus.Internal + public @NotNull DataCollectionResolver getDataCollectionResolver() { + return dataCollectionResolver; + } + /** * Adds a Scope observer * diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt new file mode 100644 index 00000000000..73f1ba93dc9 --- /dev/null +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -0,0 +1,231 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test + +class DataCollectionResolverTest { + @Test + fun `one resolver is reused per options instance`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver).isSameInstanceAs(options.dataCollectionResolver) + } + + @Test + fun `each options instance owns its resolver`() { + val first = SentryOptions() + val second = SentryOptions() + + assertThat(first.dataCollectionResolver).isNotSameInstanceAs(second.dataCollectionResolver) + } + + @Test + fun `data collection configured reflects namespace explicitness`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isDataCollectionConfigured).isFalse() + + options.dataCollection.queryParams = KeyValueCollectionBehavior.denyList() + + assertThat(options.dataCollectionResolver.isDataCollectionConfigured).isTrue() + } + + @Test + fun `user info falls back to sendDefaultPii when unset`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isUserInfo).isFalse() + + options.isSendDefaultPii = true + + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + } + + @Test + fun `user info override takes precedence over sendDefaultPii`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + options.dataCollection.setUserInfo(false) + + assertThat(options.dataCollectionResolver.isUserInfo).isFalse() + + options.isSendDefaultPii = false + options.dataCollection.setUserInfo(true) + + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + } + + @Test + fun `omitted booleans use data collection defaults once namespace is explicit`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + + options.dataCollection.cookies = KeyValueCollectionBehavior.off() + + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isTrue() + assertThat(options.dataCollectionResolver.isGraphqlDocument).isTrue() + assertThat(options.dataCollectionResolver.isGraphqlVariables).isTrue() + } + + @Test + fun `database query data falls back to sendDefaultPii and override takes precedence`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isTrue() + + options.dataCollection.setDatabaseQueryData(false) + + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isFalse() + } + + @Test + fun `GraphQL document falls back to sendDefaultPii and override takes precedence`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + assertThat(options.dataCollectionResolver.isGraphqlDocument).isTrue() + + options.dataCollection.graphql.setDocument(false) + + assertThat(options.dataCollectionResolver.isGraphqlDocument).isFalse() + } + + @Test + fun `cookies are off when unset and sendDefaultPii is false`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.cookies).isEqualTo(KeyValueCollectionBehavior.off()) + } + + @Test + fun `cookies use default deny list when unset and sendDefaultPii is true`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + assertThat(options.dataCollectionResolver.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList()) + } + + @Test + fun `cookies use default deny list when namespace is explicit`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + + options.dataCollection.setUserInfo(false) + + assertThat(options.dataCollectionResolver.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList()) + } + + @Test + fun `cookies override takes precedence over sendDefaultPii`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + val behavior = KeyValueCollectionBehavior.allowList("language", "theme") + + options.dataCollection.cookies = behavior + + assertThat(options.dataCollectionResolver.cookies).isEqualTo(behavior) + } + + @Test + fun `query params use default deny list when unset`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.queryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList()) + } + + @Test + fun `query params override takes precedence`() { + val options = SentryOptions() + val behavior = KeyValueCollectionBehavior.allowList("language", "theme") + + options.dataCollection.queryParams = behavior + + assertThat(options.dataCollectionResolver.queryParams).isEqualTo(behavior) + } + + @Test + fun `HTTP request headers use default deny list when unset`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.httpRequestHeaders) + .isEqualTo(KeyValueCollectionBehavior.denyList()) + } + + @Test + fun `HTTP request headers override takes precedence`() { + val options = SentryOptions() + val behavior = KeyValueCollectionBehavior.allowList("content-type") + + options.dataCollection.httpHeaders.request = behavior + + assertThat(options.dataCollectionResolver.httpRequestHeaders).isEqualTo(behavior) + } + + @Test + fun `HTTP response headers use default deny list when unset`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.httpResponseHeaders) + .isEqualTo(KeyValueCollectionBehavior.denyList()) + } + + @Test + fun `HTTP response headers override takes precedence`() { + val options = SentryOptions() + val behavior = KeyValueCollectionBehavior.off() + + options.dataCollection.httpHeaders.response = behavior + + assertThat(options.dataCollectionResolver.httpResponseHeaders).isEqualTo(behavior) + } + + @Test + fun `HTTP bodies preserve direction-specific legacy fallbacks when data collection is absent`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isIncomingRequestBody).isFalse() + assertThat(options.dataCollectionResolver.isOutgoingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isIncomingResponseBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingResponseBody).isFalse() + + options.isSendDefaultPii = true + + assertThat(options.dataCollectionResolver.isIncomingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isIncomingResponseBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingResponseBody).isTrue() + } + + @Test + fun `explicit empty data collection enables every HTTP body direction`() { + val options = SentryOptions().apply { dataCollection = DataCollection() } + + assertThat(options.dataCollectionResolver.isIncomingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isIncomingResponseBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingResponseBody).isTrue() + } + + @Test + fun `explicit HTTP body set controls every direction`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + options.dataCollection.httpBodies = + setOf(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + + assertThat(options.dataCollectionResolver.isIncomingRequestBody).isTrue() + assertThat(options.dataCollectionResolver.isOutgoingRequestBody).isFalse() + assertThat(options.dataCollectionResolver.isIncomingResponseBody).isFalse() + assertThat(options.dataCollectionResolver.isOutgoingResponseBody).isTrue() + } + + @Test + fun `GraphQL variables fall back to sendDefaultPii and override takes precedence`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + assertThat(options.dataCollectionResolver.isGraphqlVariables).isTrue() + + options.dataCollection.graphql.setVariables(false) + + assertThat(options.dataCollectionResolver.isGraphqlVariables).isFalse() + } +} From 9c7a845677d49a4479ecdd84d0a6e0a6d20d6e4d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Jul 2026 15:16:35 +0200 Subject: [PATCH 06/63] feat(graphql): Apply Data Collection options Control GraphQL documents and variables through the new Data Collection policies across GraphQL and Apollo integrations. Preserve sendDefaultPii and maxRequestBodySize behavior when Data Collection is absent. Co-Authored-By: Claude --- sentry-apollo-3/api/sentry-apollo-3.api | 2 + .../apollo3/SentryApollo3HttpInterceptor.kt | 7 +- .../apollo3/SentryApollo3Interceptor.kt | 24 +-- .../apollo3/SentryApolloBuilderExtensions.kt | 2 +- .../SentryApollo3InterceptorClientErrors.kt | 56 +++++++ ...ntryApollo3InterceptorWithVariablesTest.kt | 26 +++- .../apollo4/SentryApollo4HttpInterceptor.kt | 7 +- .../apollo4/SentryApollo4Interceptor.kt | 12 +- .../apollo4/SentryApolloBuilderExtensions.kt | 2 +- ...pollo4BuilderExtensionsClientErrorsTest.kt | 56 +++++++ .../SentryApollo4BuilderExtensionsTest.kt | 27 +++- .../sentry/apollo/SentryApolloInterceptor.kt | 4 +- .../apollo/SentryApolloInterceptorTest.kt | 18 +++ .../io/sentry/graphql/ExceptionReporter.java | 29 +++- .../sentry/graphql/ExceptionReporterTest.kt | 140 ++++++++++++++++++ sentry/api/sentry.api | 8 + .../io/sentry/DataCollectionResolver.java | 32 +++- .../java/io/sentry/util/GraphqlUtils.java | 53 +++++++ .../io/sentry/DataCollectionResolverTest.kt | 75 ++++++++-- 19 files changed, 535 insertions(+), 45 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/util/GraphqlUtils.java diff --git a/sentry-apollo-3/api/sentry-apollo-3.api b/sentry-apollo-3/api/sentry-apollo-3.api index e106585156f..9df63356733 100644 --- a/sentry-apollo-3/api/sentry-apollo-3.api +++ b/sentry-apollo-3/api/sentry-apollo-3.api @@ -35,6 +35,8 @@ public final class io/sentry/apollo3/SentryApollo3HttpInterceptor$Companion { public final class io/sentry/apollo3/SentryApollo3Interceptor : com/apollographql/apollo3/interceptor/ApolloInterceptor { public fun ()V + public fun (Lio/sentry/IScopes;)V + public synthetic fun (Lio/sentry/IScopes;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun intercept (Lcom/apollographql/apollo3/api/ApolloRequest;Lcom/apollographql/apollo3/interceptor/ApolloInterceptorChain;)Lkotlinx/coroutines/flow/Flow; } diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 8337eeb7b15..450681de94b 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -27,6 +27,7 @@ import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request import io.sentry.protocol.Response +import io.sentry.util.GraphqlUtils import io.sentry.util.HttpUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform @@ -174,7 +175,9 @@ constructor( operationId?.let { setData("operationId", it) } - variables?.let { setData("variables", it) } + if (scopes.options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways) { + variables?.let { setData("variables", it) } + } setData(HTTP_METHOD_KEY, method.uppercase()) } } @@ -366,7 +369,7 @@ constructor( try { it.writeTo(buffer) - data = buffer.readUtf8() + data = GraphqlUtils.filterRequestBody(buffer.readUtf8(), scopes.options) } catch (e: Throwable) { scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) // continue because the response body alone can already give some insights diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3Interceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3Interceptor.kt index ea0fa1fa18e..b58a2551566 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3Interceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3Interceptor.kt @@ -10,12 +10,16 @@ import com.apollographql.apollo3.api.Subscription import com.apollographql.apollo3.api.variables import com.apollographql.apollo3.interceptor.ApolloInterceptor import com.apollographql.apollo3.interceptor.ApolloInterceptorChain +import io.sentry.IScopes +import io.sentry.ScopesAdapter import io.sentry.apollo3.SentryApollo3HttpInterceptor.Companion.SENTRY_APOLLO_3_OPERATION_TYPE import io.sentry.apollo3.SentryApollo3HttpInterceptor.Companion.SENTRY_APOLLO_3_VARIABLES import io.sentry.vendor.Base64 import kotlinx.coroutines.flow.Flow -class SentryApollo3Interceptor : ApolloInterceptor { +class SentryApollo3Interceptor +@JvmOverloads +constructor(private val scopes: IScopes = ScopesAdapter.getInstance()) : ApolloInterceptor { override fun intercept( request: ApolloRequest, chain: ApolloInterceptorChain, @@ -28,14 +32,16 @@ class SentryApollo3Interceptor : ApolloInterceptor { Base64.encodeToString(operationType(request).toByteArray(), Base64.NO_WRAP), ) - request.scalarAdapters?.let { - builder.addHttpHeader( - SENTRY_APOLLO_3_VARIABLES, - Base64.encodeToString( - request.operation.variables(it).valueMap.toString().toByteArray(), - Base64.NO_WRAP, - ), - ) + if (scopes.options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways) { + request.scalarAdapters?.let { + builder.addHttpHeader( + SENTRY_APOLLO_3_VARIABLES, + Base64.encodeToString( + request.operation.variables(it).valueMap.toString().toByteArray(), + Base64.NO_WRAP, + ), + ) + } } return chain.proceed(builder.build()) } diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt index b5498a31316..076cfea521d 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt @@ -13,7 +13,7 @@ fun ApolloClient.Builder.sentryTracing( failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), beforeSpan: SentryApollo3HttpInterceptor.BeforeSpanCallback? = null, ): ApolloClient.Builder { - addInterceptor(SentryApollo3Interceptor()) + addInterceptor(SentryApollo3Interceptor(scopes)) addHttpInterceptor( SentryApollo3HttpInterceptor( scopes = scopes, diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt index 78be36f83b0..2b4eed0aa4c 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt @@ -72,6 +72,7 @@ class SentryApollo3InterceptorClientErrors { responseBody: String = responseBodyOk, sendDefaultPii: Boolean = false, socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + configureOptions: SentryOptions.() -> Unit = {}, ): ApolloClient { SentryIntegrationPackageStorage.getInstance().clearStorage() @@ -83,6 +84,7 @@ class SentryApollo3InterceptorClientErrors { dsn = "https://key@sentry.io/proj" sdkVersion = SdkVersion("test", "1.2.3") isSendDefaultPii = sendDefaultPii + configureOptions() } ) } @@ -266,6 +268,60 @@ class SentryApollo3InterceptorClientErrors { ) } + @Test + fun `data collection can disable the GraphQL document independently`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setDocument(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val body = it.request!!.data as String + assertFalse(body.contains("\"query\"")) + assertTrue(body.contains("\"variables\"")) + }, + any(), + ) + } + + @Test + fun `data collection can disable GraphQL variables independently`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setVariables(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val body = it.request!!.data as String + assertTrue(body.contains("\"query\"")) + assertFalse(body.contains("\"variables\"")) + }, + any(), + ) + } + + @Test + fun `data collection can disable the GraphQL request body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setDocument(false) + dataCollection.graphql.setVariables(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertNull(it.request!!.data) }, + any(), + ) + } + @Test fun `capture errors with more request context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt index 9d0028b5db7..a77c3b6ecd4 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt @@ -55,9 +55,9 @@ class SentryApollo3InterceptorWithVariablesTest { }""", socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, beforeSpan: BeforeSpanCallback? = null, + options: SentryOptions = SentryOptions().apply { dsn = "http://key@localhost/proj" }, ): ApolloClient { - whenever(scopes.options) - .thenReturn(SentryOptions().apply { dsn = "http://key@localhost/proj" }) + whenever(scopes.options).thenReturn(options) server.enqueue( MockResponse() @@ -91,6 +91,28 @@ class SentryApollo3InterceptorWithVariablesTest { ) } + @Test + fun `does not attach GraphQL variables when data collection disables them`() { + val options = + SentryOptions().apply { + dsn = "http://key@localhost/proj" + dataCollection.graphql.setVariables(false) + } + + executeQuery(fixture.getSut(options = options)) + + verify(fixture.scopes) + .captureTransaction( + check { + assertNull(it.spans.first().data?.get("variables")) + assertNotNull(it.spans.first().data?.get("operationId")) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `creates a span around the failed request`() { executeQuery(fixture.getSut(httpStatusCode = 403)) diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index fcf50564e5a..697ef81e571 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -25,6 +25,7 @@ import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request import io.sentry.protocol.Response +import io.sentry.util.GraphqlUtils import io.sentry.util.HttpUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform @@ -173,7 +174,9 @@ constructor( operationId?.let { setData("operationId", it) } - variables?.let { setData("variables", it) } + if (scopes.options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways) { + variables?.let { setData("variables", it) } + } setData(HTTP_METHOD_KEY, method.uppercase(Locale.ROOT)) } } @@ -365,7 +368,7 @@ constructor( try { it.writeTo(buffer) - data = buffer.readUtf8() + data = GraphqlUtils.filterRequestBody(buffer.readUtf8(), scopes.options) } catch (e: Throwable) { scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) // continue because the response body alone can already give some insights diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt index 5e0b882aad6..2481e2893d4 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt @@ -35,11 +35,13 @@ constructor(@ApiStatus.Internal private val scopes: IScopes = ScopesAdapter.getI .addHttpHeader(OPERATION_NAME_HEADER_NAME, encodeHeaderValue(request.operation.name())) .addHttpHeader(OPERATION_TYPE_HEADER_NAME, encodeHeaderValue(operationType(request))) - request.scalarAdapters?.let { - builder.addHttpHeader( - VARIABLES_HEADER_NAME, - encodeHeaderValue(request.operation.variables(it).valueMap.toString()), - ) + if (scopes.options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways) { + request.scalarAdapters?.let { + builder.addHttpHeader( + VARIABLES_HEADER_NAME, + encodeHeaderValue(request.operation.variables(it).valueMap.toString()), + ) + } } return chain.proceed(builder.build()) diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt index 61ff468d265..51383d33ed7 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt @@ -13,7 +13,7 @@ fun ApolloClient.Builder.sentryTracing( failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), beforeSpan: SentryApollo4HttpInterceptor.BeforeSpanCallback? = null, ): ApolloClient.Builder { - addInterceptor(SentryApollo4Interceptor()) + addInterceptor(SentryApollo4Interceptor(scopes)) addHttpInterceptor( SentryApollo4HttpInterceptor( scopes = scopes, diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt index 0572e4f1323..fe870a8f9f1 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -86,6 +86,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( responseBody: String = responseBodyOk, sendDefaultPii: Boolean = false, socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + configureOptions: SentryOptions.() -> Unit = {}, ): ApolloClient { SentryIntegrationPackageStorage.getInstance().clearStorage() @@ -97,6 +98,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( dsn = "https://key@sentry.io/proj" sdkVersion = SdkVersion("test", "1.2.3") isSendDefaultPii = sendDefaultPii + configureOptions() } ) } @@ -280,6 +282,60 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection can disable the GraphQL document independently`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setDocument(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val body = it.request!!.data as String + assertFalse(body.contains("\"query\"")) + assertTrue(body.contains("\"variables\"")) + }, + any(), + ) + } + + @Test + fun `data collection can disable GraphQL variables independently`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setVariables(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val body = it.request!!.data as String + assertTrue(body.contains("\"query\"")) + assertFalse(body.contains("\"variables\"")) + }, + any(), + ) + } + + @Test + fun `data collection can disable the GraphQL request body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.graphql.setDocument(false) + dataCollection.graphql.setVariables(false) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertNull(it.request!!.data) }, + any(), + ) + } + @Test fun `capture errors with more request context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt index 2c5b23adc4f..654ff307eba 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt @@ -23,6 +23,7 @@ import kotlin.reflect.KSuspendFunction1 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -69,9 +70,9 @@ abstract class SentryApollo4BuilderExtensionsTest( }""", socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, beforeSpan: BeforeSpanCallback? = null, + options: SentryOptions = SentryOptions().apply { dsn = "http://key@localhost/proj" }, ): ApolloClient { - whenever(scopes.options) - .thenReturn(SentryOptions().apply { dsn = "http://key@localhost/proj" }) + whenever(scopes.options).thenReturn(options) server.enqueue( MockResponse() @@ -105,6 +106,28 @@ abstract class SentryApollo4BuilderExtensionsTest( ) } + @Test + fun `does not attach GraphQL variables when data collection disables them`() { + val options = + SentryOptions().apply { + dsn = "http://key@localhost/proj" + dataCollection.graphql.setVariables(false) + } + + executeQuery(fixture.getSut(options = options)) + + verify(fixture.scopes) + .captureTransaction( + check { + assertNull(it.spans.first().data?.get("variables")) + assertNotNull(it.spans.first().data?.get("operationId")) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `creates span around failed request`() { executeQuery(fixture.getSut(httpStatusCode = 403)) diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt index e496d1055f3..b4fc25e7be2 100644 --- a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -74,7 +74,9 @@ class SentryApolloInterceptor( val requestWithHeader = request.toBuilder().requestHeaders(headers).build() span.setData("operationId", requestWithHeader.operation.operationId()) - span.setData("variables", requestWithHeader.operation.variables().valueMap().toString()) + if (scopes.options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways) { + span.setData("variables", requestWithHeader.operation.variables().valueMap().toString()) + } chain.proceedAsync( requestWithHeader, diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt index aaf9b30b7f3..d43fe40c9e4 100644 --- a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -121,6 +121,24 @@ class SentryApolloInterceptorTest { ) } + @Test + fun `does not attach GraphQL variables when data collection disables them`() { + fixture.options.dataCollection.graphql.setVariables(false) + + executeQuery() + + verify(fixture.scopes) + .captureTransaction( + check { + assertNull(it.spans.first().data?.get("variables")) + assertNotNull(it.spans.first().data?.get("operationId")) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `creates a span around the failed request`() { executeQuery(fixture.getSut(httpStatusCode = 403)) diff --git a/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java index 9bca0955e40..d53a6376e01 100644 --- a/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java @@ -45,7 +45,7 @@ public void captureThrowable( final @NotNull Hint hint = new Hint(); setRequestDetailsOnEvent(scopes, exceptionDetails, event); - if (result != null && isAllowedToAttachBody(scopes)) { + if (result != null && isAllowedToAttachResponseBody(scopes)) { final @NotNull Response response = new Response(); final @NotNull Map responseBody = result.toSpecification(); response.setData(responseBody); @@ -55,7 +55,13 @@ public void captureThrowable( scopes.captureEvent(event, hint); } - private boolean isAllowedToAttachBody(final @NotNull IScopes scopes) { + private boolean isAllowedToAttachRequestBody(final @NotNull IScopes scopes) { + final @NotNull SentryOptions options = scopes.getOptions(); + return options.getDataCollectionResolver().isGraphqlDocumentWithLegacyBodyGate() + || options.getDataCollectionResolver().isGraphqlVariablesWithLegacyBodyGate(); + } + + private boolean isAllowedToAttachResponseBody(final @NotNull IScopes scopes) { final @NotNull SentryOptions options = scopes.getOptions(); return options.isSendDefaultPii() && !SentryOptions.RequestSize.NONE.equals(options.getMaxRequestBodySize()); @@ -80,20 +86,27 @@ private void setDetailsOnRequest( final @NotNull Request request) { request.setApiTarget("graphql"); - if (isAllowedToAttachBody(scopes) + if (isAllowedToAttachRequestBody(scopes) && (exceptionDetails.isSubscription() || captureRequestBodyForNonSubscriptions)) { final @NotNull Map data = new HashMap<>(); + final @NotNull SentryOptions options = scopes.getOptions(); - data.put("query", exceptionDetails.getQuery()); + if (options.getDataCollectionResolver().isGraphqlDocumentWithLegacyBodyGate()) { + data.put("query", exceptionDetails.getQuery()); + } - final @Nullable Map variables = exceptionDetails.getVariables(); - if (variables != null && !variables.isEmpty()) { - data.put("variables", variables); + if (options.getDataCollectionResolver().isGraphqlVariablesWithLegacyBodyGate()) { + final @Nullable Map variables = exceptionDetails.getVariables(); + if (variables != null && !variables.isEmpty()) { + data.put("variables", variables); + } } // for Spring HTTP this will be replaced by RequestBodyExtractingEventProcessor // for non subscription (websocket) errors - request.setData(data); + if (!data.isEmpty()) { + request.setData(data); + } } } diff --git a/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt index 759591d323c..316edf53ff4 100644 --- a/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt +++ b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt @@ -14,6 +14,7 @@ import graphql.schema.GraphQLSchema import io.sentry.Hint import io.sentry.IScope import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions @@ -221,6 +222,37 @@ class ExceptionReporterTest { ) } + @Test + fun `data collection ignores the legacy max request body size option`() { + val options = + SentryOptions().also { + it.maxRequestBodySize = SentryOptions.RequestSize.NONE + it.dataCollection.graphql.setDocument(true) + it.dataCollection.graphql.setVariables(true) + } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { + val data = it.request!!.data as Map + assertEquals(fixture.query, data["query"]) + assertEquals(fixture.variables, data["variables"]) + }, + any(), + ) + } + @Test fun `does not attach query or variables if sendDefaultPii is false`() { val exceptionReporter = @@ -254,6 +286,114 @@ class ExceptionReporterTest { ) } + @Test + fun `data collection can disable the query independently`() { + val options = fixture.defaultOptions + options.dataCollection.graphql.setDocument(false) + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { + val data = it.request!!.data as Map + assertNull(data["query"]) + assertEquals(fixture.variables, data["variables"]) + }, + any(), + ) + } + + @Test + fun `data collection can disable variables independently`() { + val options = fixture.defaultOptions + options.dataCollection.graphql.setVariables(false) + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { + val data = it.request!!.data as Map + assertEquals(fixture.query, data["query"]) + assertNull(data["variables"]) + }, + any(), + ) + } + + @Test + fun `data collection can disable both query and variables`() { + val options = fixture.defaultOptions + options.dataCollection.graphql.setDocument(false) + options.dataCollection.graphql.setVariables(false) + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNull(it.request!!.data) }, + any(), + ) + } + + @Test + fun `data collection namespace defaults enable query and variables`() { + val options = + SentryOptions().also { + it.maxRequestBodySize = SentryOptions.RequestSize.ALWAYS + it.dataCollection.cookies = KeyValueCollectionBehavior.off() + } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { + val data = it.request!!.data as Map + assertEquals(fixture.query, data["query"]) + assertEquals(fixture.variables, data["variables"]) + }, + any(), + ) + } + @Test fun `attaches query and variables if spring and subscription`() { val exceptionReporter = fixture.getSut(captureRequestBodyForNonSubscriptions = false) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index bfdc8ff97fe..4ab82709159 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -425,7 +425,11 @@ public final class io/sentry/DataCollectionResolver { public fun isDataCollectionConfigured ()Z public fun isDatabaseQueryData ()Z public fun isGraphqlDocument ()Z + public fun isGraphqlDocumentWithLegacyAlways ()Z + public fun isGraphqlDocumentWithLegacyBodyGate ()Z public fun isGraphqlVariables ()Z + public fun isGraphqlVariablesWithLegacyAlways ()Z + public fun isGraphqlVariablesWithLegacyBodyGate ()Z public fun isIncomingRequestBody ()Z public fun isIncomingResponseBody ()Z public fun isOutgoingRequestBody ()Z @@ -7765,6 +7769,10 @@ public final class io/sentry/util/FileUtils { public static fun readText (Ljava/io/File;)Ljava/lang/String; } +public final class io/sentry/util/GraphqlUtils { + public static fun filterRequestBody (Ljava/lang/String;Lio/sentry/SentryOptions;)Ljava/lang/String; +} + public final class io/sentry/util/HintUtils { public static fun createWithTypeCheckHint (Ljava/lang/Object;)Lio/sentry/Hint; public static fun getEventDropReason (Lio/sentry/Hint;)Lio/sentry/hints/EventDropReason; diff --git a/sentry/src/main/java/io/sentry/DataCollectionResolver.java b/sentry/src/main/java/io/sentry/DataCollectionResolver.java index 3063268f0f7..cdfb0649188 100644 --- a/sentry/src/main/java/io/sentry/DataCollectionResolver.java +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -35,10 +35,30 @@ public boolean isGraphqlDocument() { return explicitOrSendDefaultPii(options.getDataCollection().getGraphql().getDocument(), true); } + public boolean isGraphqlDocumentWithLegacyBodyGate() { + return explicitOrDefault( + options.getDataCollection().getGraphql().getDocument(), true, isLegacyGraphqlBodyEnabled()); + } + + public boolean isGraphqlDocumentWithLegacyAlways() { + return explicitOrDefault(options.getDataCollection().getGraphql().getDocument(), true, true); + } + public boolean isGraphqlVariables() { return explicitOrSendDefaultPii(options.getDataCollection().getGraphql().getVariables(), true); } + public boolean isGraphqlVariablesWithLegacyBodyGate() { + return explicitOrDefault( + options.getDataCollection().getGraphql().getVariables(), + true, + isLegacyGraphqlBodyEnabled()); + } + + public boolean isGraphqlVariablesWithLegacyAlways() { + return explicitOrDefault(options.getDataCollection().getGraphql().getVariables(), true, true); + } + public @NotNull KeyValueCollectionBehavior getCookies() { final @NotNull DataCollection dataCollection = options.getDataCollection(); final @Nullable KeyValueCollectionBehavior cookies = dataCollection.getCookies(); @@ -80,12 +100,22 @@ public boolean isOutgoingResponseBody() { return isHttpBodyEnabled(HttpBodyType.OUTGOING_RESPONSE, options.isSendDefaultPii()); } + private boolean isLegacyGraphqlBodyEnabled() { + return options.isSendDefaultPii() + && !SentryOptions.RequestSize.NONE.equals(options.getMaxRequestBodySize()); + } + private boolean explicitOrSendDefaultPii( final @Nullable Boolean explicit, final boolean defaultValue) { + return explicitOrDefault(explicit, defaultValue, options.isSendDefaultPii()); + } + + private boolean explicitOrDefault( + final @Nullable Boolean explicit, final boolean defaultValue, final boolean legacyFallback) { if (explicit != null) { return explicit; } - return isDataCollectionConfigured() ? defaultValue : options.isSendDefaultPii(); + return isDataCollectionConfigured() ? defaultValue : legacyFallback; } private @NotNull KeyValueCollectionBehavior explicitOrEmptyDenyList( diff --git a/sentry/src/main/java/io/sentry/util/GraphqlUtils.java b/sentry/src/main/java/io/sentry/util/GraphqlUtils.java new file mode 100644 index 00000000000..30c164e3a00 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/GraphqlUtils.java @@ -0,0 +1,53 @@ +package io.sentry.util; + +import io.sentry.DataCollectionResolver; +import io.sentry.JsonObjectReader; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import java.io.StringReader; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class GraphqlUtils { + + private GraphqlUtils() {} + + public static @Nullable String filterRequestBody( + final @NotNull String body, final @NotNull SentryOptions options) { + final @NotNull DataCollectionResolver resolver = options.getDataCollectionResolver(); + final boolean includeDocument = resolver.isGraphqlDocumentWithLegacyAlways(); + final boolean includeVariables = resolver.isGraphqlVariablesWithLegacyAlways(); + + if (includeDocument && includeVariables) { + return body; + } + if (!includeDocument && !includeVariables) { + return null; + } + + try (JsonObjectReader reader = new JsonObjectReader(new StringReader(body))) { + final @Nullable Object value = reader.nextObjectOrNull(); + if (!(value instanceof Map)) { + return null; + } + + @SuppressWarnings("unchecked") + final @NotNull Map requestBody = (Map) value; + final @NotNull Map filtered = new LinkedHashMap<>(requestBody); + if (!includeDocument) { + filtered.remove("query"); + } + if (!includeVariables) { + filtered.remove("variables"); + } + return options.getSerializer().serialize(filtered); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to filter GraphQL request body.", e); + return null; + } + } +} diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt index 73f1ba93dc9..47f637f0a08 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -89,6 +89,70 @@ class DataCollectionResolverTest { assertThat(options.dataCollectionResolver.isGraphqlDocument).isFalse() } + @Test + fun `GraphQL variables fall back to sendDefaultPii and override takes precedence`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + assertThat(options.dataCollectionResolver.isGraphqlVariables).isTrue() + + options.dataCollection.graphql.setVariables(false) + + assertThat(options.dataCollectionResolver.isGraphqlVariables).isFalse() + } + + @Test + fun `GraphQL legacy body variants preserve the legacy size gate when namespace is absent`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + maxRequestBodySize = SentryOptions.RequestSize.NONE + } + + assertThat(options.dataCollectionResolver.isGraphqlDocumentWithLegacyBodyGate).isFalse() + assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyBodyGate).isFalse() + + options.maxRequestBodySize = SentryOptions.RequestSize.SMALL + + assertThat(options.dataCollectionResolver.isGraphqlDocumentWithLegacyBodyGate).isTrue() + assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyBodyGate).isTrue() + } + + @Test + fun `GraphQL legacy body variants ignore the size option when namespace is explicit`() { + val options = + SentryOptions().apply { + isSendDefaultPii = false + maxRequestBodySize = SentryOptions.RequestSize.NONE + dataCollection.graphql.setDocument(true) + dataCollection.graphql.setVariables(true) + } + + assertThat(options.dataCollectionResolver.isGraphqlDocumentWithLegacyBodyGate).isTrue() + assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyBodyGate).isTrue() + } + + @Test + fun `GraphQL document legacy always variant preserves collection when namespace is absent`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + + assertThat(options.dataCollectionResolver.isGraphqlDocumentWithLegacyAlways).isTrue() + + options.dataCollection.graphql.setDocument(false) + + assertThat(options.dataCollectionResolver.isGraphqlDocumentWithLegacyAlways).isFalse() + } + + @Test + fun `GraphQL variables legacy always variant preserves collection when namespace is absent`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + + assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways).isTrue() + + options.dataCollection.graphql.setVariables(false) + + assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyAlways).isFalse() + } + @Test fun `cookies are off when unset and sendDefaultPii is false`() { val options = SentryOptions() @@ -217,15 +281,4 @@ class DataCollectionResolverTest { assertThat(options.dataCollectionResolver.isIncomingResponseBody).isFalse() assertThat(options.dataCollectionResolver.isOutgoingResponseBody).isTrue() } - - @Test - fun `GraphQL variables fall back to sendDefaultPii and override takes precedence`() { - val options = SentryOptions().apply { isSendDefaultPii = true } - - assertThat(options.dataCollectionResolver.isGraphqlVariables).isTrue() - - options.dataCollection.graphql.setVariables(false) - - assertThat(options.dataCollectionResolver.isGraphqlVariables).isFalse() - } } From be3834c518ca21eeda2e327efc62af2cafe5a1f2 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Jul 2026 11:45:40 +0200 Subject: [PATCH 07/63] feat(database): Apply Data Collection query policy Suppress SQL statement descriptions when database query data collection is disabled while retaining database system, name, timing, status, and other structural span metadata. Preserve existing statement collection when Data Collection is absent. Co-Authored-By: Claude --- .../sentry/android/sqlite/OpenHelperSpans.kt | 13 +++++++++-- .../main/java/io/sentry/sqlite/DriverSpans.kt | 5 +++- .../android/sqlite/OpenHelperSpansTest.kt | 22 ++++++++++++++++++ .../java/io/sentry/sqlite/DriverSpansTest.kt | 22 ++++++++++++++++++ .../sentry/jdbc/SentryJdbcEventListener.java | 6 ++++- .../jdbc/SentryJdbcEventListenerTest.kt | 23 +++++++++++++++++++ sentry/api/sentry.api | 1 + .../io/sentry/DataCollectionResolver.java | 4 ++++ .../io/sentry/DataCollectionResolverTest.kt | 11 +++++++++ 9 files changed, 103 insertions(+), 4 deletions(-) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt index 059eb1bb1b5..4fe75ef4d28 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt @@ -6,6 +6,7 @@ import io.sentry.IScopes import io.sentry.ISpan import io.sentry.Instrumenter import io.sentry.ScopesAdapter +import io.sentry.SentryDate import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryStackTraceFactory import io.sentry.SpanDataConvention @@ -46,12 +47,12 @@ internal class OpenHelperSpans( if (result is CrossProcessCursor) { return SentryCrossProcessCursor(result, this, sql) as T } - span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span = startSpan(sql, startTimestamp) span?.spanContext?.origin = TRACE_ORIGIN span?.status = SpanStatus.OK result } catch (e: Throwable) { - span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span = startSpan(sql, startTimestamp) span?.spanContext?.origin = TRACE_ORIGIN span?.status = SpanStatus.INTERNAL_ERROR span?.throwable = e @@ -76,4 +77,12 @@ internal class OpenHelperSpans( } } } + + private fun startSpan(sql: String, startTimestamp: SentryDate): ISpan? = + scopes.span?.startChild( + "db.sql.query", + sql.takeIf { scopes.options.dataCollectionResolver.isDatabaseQueryDataWithLegacyAlways }, + startTimestamp, + Instrumenter.SENTRY, + ) } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt index b3c0eb7c713..fe2b15a33bb 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt @@ -50,7 +50,10 @@ internal class DriverSpans(private val scopes: IScopes, private val dbMetadata: val startTimestamp = SentryLongDate(startTimestampNanos) val endTimestamp = SentryLongDate(startTimestampNanos + durationNanos) - parent.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY).apply { + val description = sql.takeIf { + scopes.options.dataCollectionResolver.isDatabaseQueryDataWithLegacyAlways + } + parent.startChild("db.sql.query", description, startTimestamp, Instrumenter.SENTRY).apply { spanContext.origin = SQLITE_TRACE_ORIGIN throwable?.let { this.throwable = it } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt index 0552094838e..8b442c59ee5 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt @@ -66,6 +66,28 @@ class OpenHelperSpansTest { assertTrue(span.isFinished) } + @Test + fun `performSql omits description when database query data is disabled`() { + val sut = fixture.getSut() + fixture.options.dataCollection.setDatabaseQueryData(false) + + sut.performSql("SELECT secret FROM users") {} + + val span = fixture.sentryTracer.children.first() + assertNull(span.description) + assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + } + + @Test + fun `performSql keeps description in legacy mode`() { + val sut = fixture.getSut() + fixture.options.isSendDefaultPii = false + + sut.performSql("SELECT secret FROM users") {} + + assertEquals("SELECT secret FROM users", fixture.sentryTracer.children.first().description) + } + @Test fun `performSql does not create a span if no span is running`() { val sut = fixture.getSut(isSpanActive = false) diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt index 319fc20d7ce..2265d10aa75 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt @@ -129,6 +129,28 @@ class DriverSpansTest { assertTrue(span.isFinished) } + @Test + fun `record method omits description when database query data is disabled`() { + val sut = fixture.getSut() + fixture.options.dataCollection.setDatabaseQueryData(false) + + sut.record("SELECT secret FROM users", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertNull(span.description) + assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + } + + @Test + fun `record method keeps description in legacy mode`() { + val sut = fixture.getSut() + fixture.options.isSendDefaultPii = false + + sut.record("SELECT secret FROM users", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + assertEquals("SELECT secret FROM users", fixture.sentryTracer.children.first().description) + } + @Test fun `record method sets finishDate equal to startDate + durationNanos`() { val sut = fixture.getSut() diff --git a/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java b/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java index 4206de18002..59e50efae26 100644 --- a/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java +++ b/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java @@ -47,7 +47,11 @@ public SentryJdbcEventListener() { @Override public void onBeforeAnyExecute(final @NotNull StatementInformation statementInformation) { - startSpan(CURRENT_QUERY_SPAN, "db.query", statementInformation.getSql()); + final @Nullable String description = + scopes.getOptions().getDataCollectionResolver().isDatabaseQueryDataWithLegacyAlways() + ? statementInformation.getSql() + : null; + startSpan(CURRENT_QUERY_SPAN, "db.query", description); } @Override diff --git a/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt b/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt index 22ee97e5d47..436bc4abf62 100644 --- a/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt +++ b/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt @@ -90,6 +90,29 @@ class SentryJdbcEventListenerTest { assertEquals("INSERT INTO foo VALUES (2)", fixture.tx.children[1].description) } + @Test + fun `omits query description when database query data is disabled`() { + val sut = fixture.getSut() + fixture.options.dataCollection.setDatabaseQueryData(false) + + sut.connection.use { it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() } + + assertEquals(1, fixture.tx.children.size) + assertEquals(null, fixture.tx.children.first().description) + assertEquals("hsqldb", fixture.tx.children.first().data[DB_SYSTEM_KEY]) + assertEquals("testdb", fixture.tx.children.first().data[DB_NAME_KEY]) + } + + @Test + fun `legacy mode keeps query description when sendDefaultPii is false`() { + val sut = fixture.getSut() + fixture.options.isSendDefaultPii = false + + sut.connection.use { it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() } + + assertEquals("INSERT INTO foo VALUES (1)", fixture.tx.children.first().description) + } + @Test fun `creates spans for calls resulting in error`() { val sut = fixture.getSut(existingRow = 1) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4ab82709159..db9547700dc 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -424,6 +424,7 @@ public final class io/sentry/DataCollectionResolver { public fun getQueryParams ()Lio/sentry/KeyValueCollectionBehavior; public fun isDataCollectionConfigured ()Z public fun isDatabaseQueryData ()Z + public fun isDatabaseQueryDataWithLegacyAlways ()Z public fun isGraphqlDocument ()Z public fun isGraphqlDocumentWithLegacyAlways ()Z public fun isGraphqlDocumentWithLegacyBodyGate ()Z diff --git a/sentry/src/main/java/io/sentry/DataCollectionResolver.java b/sentry/src/main/java/io/sentry/DataCollectionResolver.java index cdfb0649188..a293614eb2b 100644 --- a/sentry/src/main/java/io/sentry/DataCollectionResolver.java +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -31,6 +31,10 @@ public boolean isDatabaseQueryData() { return explicitOrSendDefaultPii(options.getDataCollection().getDatabaseQueryData(), true); } + public boolean isDatabaseQueryDataWithLegacyAlways() { + return explicitOrDefault(options.getDataCollection().getDatabaseQueryData(), true, true); + } + public boolean isGraphqlDocument() { return explicitOrSendDefaultPii(options.getDataCollection().getGraphql().getDocument(), true); } diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt index 47f637f0a08..fb525ce43d2 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -78,6 +78,17 @@ class DataCollectionResolverTest { assertThat(options.dataCollectionResolver.isDatabaseQueryData).isFalse() } + @Test + fun `database query data legacy always variant preserves collection when namespace is absent`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + + assertThat(options.dataCollectionResolver.isDatabaseQueryDataWithLegacyAlways).isTrue() + + options.dataCollection.setDatabaseQueryData(false) + + assertThat(options.dataCollectionResolver.isDatabaseQueryDataWithLegacyAlways).isFalse() + } + @Test fun `GraphQL document falls back to sendDefaultPii and override takes precedence`() { val options = SentryOptions().apply { isSendDefaultPii = true } From d17418d5cb09773571349ae9541c0d2b24b08dda Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Jul 2026 12:06:38 +0200 Subject: [PATCH 08/63] feat(spring): Apply incoming request body policy Use the Data Collection incoming request body decision for servlet request caching and event body extraction across all Spring variants. Keep existing body size, content length, and MIME type limits while preserving sendDefaultPii behavior when Data Collection is absent. Co-Authored-By: Claude --- .../io/sentry/spring7/SentrySpringFilter.java | 4 +- .../sentry/spring7/SentrySpringFilterTest.kt | 47 +++++++++++++++++++ .../spring/jakarta/SentrySpringFilter.java | 4 +- .../spring/jakarta/SentrySpringFilterTest.kt | 47 +++++++++++++++++++ .../io/sentry/spring/SentrySpringFilter.java | 4 +- .../sentry/spring/SentrySpringFilterTest.kt | 47 +++++++++++++++++++ 6 files changed, 147 insertions(+), 6 deletions(-) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java index 38bc4379088..bf2c431a179 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java @@ -110,7 +110,7 @@ private void configureScope( private @NotNull HttpServletRequest resolveHttpServletRequest( final @NotNull IScopes scopes, final @NotNull HttpServletRequest request) { - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, scopes.getOptions().getMaxRequestBodySize())) { return new ContentCachingRequestWrapper(request, 0); } @@ -155,7 +155,7 @@ public RequestBodyExtractingEventProcessor( @Override public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { if (event.getRequest() != null - && options.isSendDefaultPii() + && options.getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, options.getMaxRequestBodySize())) { event.getRequest().setData(requestPayloadExtractor.extract(request, options)); } diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt index 5a83c9d72a4..532b3c686b2 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt @@ -1,6 +1,7 @@ package io.sentry.spring7 import io.sentry.Breadcrumb +import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes import io.sentry.ISentryLifecycleToken @@ -318,6 +319,52 @@ class SentrySpringFilterTest { } } + @Test + fun `data collection can enable request body when sendDefaultPii is false`() { + val options = + SentryOptions().apply { + isSendDefaultPii = false + maxRequestBodySize = SMALL + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_REQUEST) + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertTrue(it is ContentCachingRequestWrapper) }, any()) + } + + @Test + fun `data collection can disable request body when sendDefaultPii is true`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + maxRequestBodySize = SMALL + dataCollection.httpBodies = emptySet() + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertFalse(it is ContentCachingRequestWrapper) }, any()) + } + private fun servletContextWithCustomCookieName(name: String): ServletContext = MockServletContext().also { it.sessionCookieConfig.name = name } } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java index c51a2053b8d..c549223e559 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentrySpringFilter.java @@ -110,7 +110,7 @@ private void configureScope( private @NotNull HttpServletRequest resolveHttpServletRequest( final @NotNull IScopes scopes, final @NotNull HttpServletRequest request) { - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, scopes.getOptions().getMaxRequestBodySize())) { return new ContentCachingRequestWrapper(request); } @@ -155,7 +155,7 @@ public RequestBodyExtractingEventProcessor( @Override public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { if (event.getRequest() != null - && options.isSendDefaultPii() + && options.getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, options.getMaxRequestBodySize())) { event.getRequest().setData(requestPayloadExtractor.extract(request, options)); } diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt index 349839b5d15..ad6c01e99d1 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt @@ -1,6 +1,7 @@ package io.sentry.spring.jakarta import io.sentry.Breadcrumb +import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes import io.sentry.ISentryLifecycleToken @@ -318,6 +319,52 @@ class SentrySpringFilterTest { } } + @Test + fun `data collection can enable request body when sendDefaultPii is false`() { + val options = + SentryOptions().apply { + isSendDefaultPii = false + maxRequestBodySize = SMALL + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_REQUEST) + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertTrue(it is ContentCachingRequestWrapper) }, any()) + } + + @Test + fun `data collection can disable request body when sendDefaultPii is true`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + maxRequestBodySize = SMALL + dataCollection.httpBodies = emptySet() + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertFalse(it is ContentCachingRequestWrapper) }, any()) + } + private fun servletContextWithCustomCookieName(name: String): ServletContext = MockServletContext().also { it.sessionCookieConfig.name = name } } diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java b/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java index 69438c82617..3fe8ab9e13f 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentrySpringFilter.java @@ -110,7 +110,7 @@ private void configureScope( private @NotNull HttpServletRequest resolveHttpServletRequest( final @NotNull IScopes scopes, final @NotNull HttpServletRequest request) { - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, scopes.getOptions().getMaxRequestBodySize())) { return new ContentCachingRequestWrapper(request); } @@ -155,7 +155,7 @@ public RequestBodyExtractingEventProcessor( @Override public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { if (event.getRequest() != null - && options.isSendDefaultPii() + && options.getDataCollectionResolver().isIncomingRequestBody() && qualifiesForCaching(request, options.getMaxRequestBodySize())) { event.getRequest().setData(requestPayloadExtractor.extract(request, options)); } diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt index eb145bcd8a1..cfc5042dc58 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt @@ -1,6 +1,7 @@ package io.sentry.spring import io.sentry.Breadcrumb +import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes import io.sentry.ISentryLifecycleToken @@ -318,6 +319,52 @@ class SentrySpringFilterTest { } } + @Test + fun `data collection can enable request body when sendDefaultPii is false`() { + val options = + SentryOptions().apply { + isSendDefaultPii = false + maxRequestBodySize = SMALL + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_REQUEST) + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertTrue(it is ContentCachingRequestWrapper) }, any()) + } + + @Test + fun `data collection can disable request body when sendDefaultPii is true`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + maxRequestBodySize = SMALL + dataCollection.httpBodies = emptySet() + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com")) + .content("xxx") + .contentType("application/json") + .buildRequest(MockServletContext()), + options = options, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(check { assertFalse(it is ContentCachingRequestWrapper) }, any()) + } + private fun servletContextWithCustomCookieName(name: String): ServletContext = MockServletContext().also { it.sessionCookieConfig.name = name } } From fee2df43952c89ea05266d875c6c1f22dc4b9fdf Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Jul 2026 12:14:31 +0200 Subject: [PATCH 09/63] feat(apollo): Apply incoming response body policy Use the Data Collection incoming response body decision when attaching failed GraphQL response content in Apollo 3 and 4. Continue reading responses for error detection and retain status and body size metadata. Co-Authored-By: Claude --- .../apollo3/SentryApollo3HttpInterceptor.kt | 4 +++- .../SentryApollo3InterceptorClientErrors.kt | 20 +++++++++++++++++++ .../apollo4/SentryApollo4HttpInterceptor.kt | 4 +++- ...pollo4BuilderExtensionsClientErrorsTest.kt | 20 +++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 450681de94b..c59166f8503 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -394,7 +394,9 @@ constructor( response.body?.buffer?.size?.ifHasValidLength { contentLength -> bodySize = contentLength } - data = body + if (scopes.options.dataCollectionResolver.isIncomingResponseBody) { + data = body + } } fingerprints.add(response.statusCode.toString()) diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt index 2b4eed0aa4c..8e9e083a472 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt @@ -360,6 +360,26 @@ class SentryApollo3InterceptorClientErrors { ) } + @Test + fun `data collection can disable incoming response body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpBodies = emptySet() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val response = it.contexts.response!! + assertEquals(200, response.statusCode) + assertEquals(200, response.bodySize) + assertNull(response.data) + }, + any(), + ) + } + @Test fun `capture errors with more response context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index 697ef81e571..cca11759188 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -393,7 +393,9 @@ constructor( response.body?.buffer?.size?.ifHasValidLength { contentLength -> bodySize = contentLength } - data = body + if (scopes.options.dataCollectionResolver.isIncomingResponseBody) { + data = body + } } fingerprints.add(response.statusCode.toString()) diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt index fe870a8f9f1..42637a40927 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -373,6 +373,26 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection can disable incoming response body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpBodies = emptySet() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + val response = it.contexts.response!! + assertEquals(200, response.statusCode) + assertEquals(200, response.bodySize) + assertNull(response.data) + }, + any(), + ) + } + @Test fun `capture errors with more response context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) From 34dcac98ba775665179f70217950e96152c1c1c4 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Jul 2026 14:44:00 +0200 Subject: [PATCH 10/63] feat(apollo): Apply outgoing request body policy Use the Data Collection outgoing request body decision when attaching failed GraphQL request content in Apollo 3 and 4. Retain request body size metadata and continue applying GraphQL document and variable controls when body collection is enabled. Co-Authored-By: Claude --- .../apollo3/SentryApollo3HttpInterceptor.kt | 22 ++++++++++--------- .../SentryApollo3InterceptorClientErrors.kt | 19 ++++++++++++++++ .../apollo4/SentryApollo4HttpInterceptor.kt | 22 ++++++++++--------- ...pollo4BuilderExtensionsClientErrorsTest.kt | 19 ++++++++++++++++ 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index c59166f8503..835c0d75763 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -365,16 +365,18 @@ constructor( request.body?.let { bodySize = it.contentLength - val buffer = Buffer() - - try { - it.writeTo(buffer) - data = GraphqlUtils.filterRequestBody(buffer.readUtf8(), scopes.options) - } catch (e: Throwable) { - scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) - // continue because the response body alone can already give some insights - } finally { - buffer.close() + if (scopes.options.dataCollectionResolver.isOutgoingRequestBody) { + val buffer = Buffer() + + try { + it.writeTo(buffer) + data = GraphqlUtils.filterRequestBody(buffer.readUtf8(), scopes.options) + } catch (e: Throwable) { + scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) + // continue because the response body alone can already give some insights + } finally { + buffer.close() + } } } } diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt index 8e9e083a472..46c93a83136 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt @@ -5,6 +5,7 @@ import com.apollographql.apollo3.api.http.HttpRequest import com.apollographql.apollo3.api.http.HttpResponse import com.apollographql.apollo3.exception.ApolloException import io.sentry.Hint +import io.sentry.HttpBodyType import io.sentry.IScopes import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions @@ -268,6 +269,24 @@ class SentryApollo3InterceptorClientErrors { ) } + @Test + fun `data collection can disable outgoing request body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_RESPONSE) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals(193L, it.request!!.bodySize) + assertNull(it.request!!.data) + }, + any(), + ) + } + @Test fun `data collection can disable the GraphQL document independently`() { val sut = diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index cca11759188..afb3c9cba7a 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -364,16 +364,18 @@ constructor( request.body?.let { bodySize = it.contentLength - val buffer = Buffer() - - try { - it.writeTo(buffer) - data = GraphqlUtils.filterRequestBody(buffer.readUtf8(), scopes.options) - } catch (e: Throwable) { - scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) - // continue because the response body alone can already give some insights - } finally { - buffer.close() + if (scopes.options.dataCollectionResolver.isOutgoingRequestBody) { + val buffer = Buffer() + + try { + it.writeTo(buffer) + data = GraphqlUtils.filterRequestBody(buffer.readUtf8(), scopes.options) + } catch (e: Throwable) { + scopes.options.logger.log(SentryLevel.ERROR, "Error reading the request body.", e) + // continue because the response body alone can already give some insights + } finally { + buffer.close() + } } } } diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt index 42637a40927..625838225ab 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -8,6 +8,7 @@ import com.apollographql.apollo.api.http.HttpRequest import com.apollographql.apollo.api.http.HttpResponse import com.apollographql.apollo.exception.ApolloException import io.sentry.Hint +import io.sentry.HttpBodyType import io.sentry.IScopes import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions @@ -282,6 +283,24 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection can disable outgoing request body`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpBodies = setOf(HttpBodyType.INCOMING_RESPONSE) + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals(193L, it.request!!.bodySize) + assertNull(it.request!!.data) + }, + any(), + ) + } + @Test fun `data collection can disable the GraphQL document independently`() { val sut = From fa5bc98875613fc18e77c836c31b021f0ab2eec8 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Jul 2026 15:17:50 +0200 Subject: [PATCH 11/63] feat(graphql): Apply outgoing response body policy Use the Data Collection outgoing response body decision when attaching GraphQL execution results. Preserve the sendDefaultPii and maxRequestBodySize gate when Data Collection is absent. Co-Authored-By: Claude --- .../io/sentry/graphql/ExceptionReporter.java | 7 +- .../sentry/graphql/ExceptionReporterTest.kt | 119 ++++++++++++++++++ sentry/api/sentry.api | 1 + .../io/sentry/DataCollectionResolver.java | 4 + .../io/sentry/DataCollectionResolverTest.kt | 31 +++++ 5 files changed, 159 insertions(+), 3 deletions(-) diff --git a/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java index d53a6376e01..4330a49e22d 100644 --- a/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java @@ -62,9 +62,10 @@ private boolean isAllowedToAttachRequestBody(final @NotNull IScopes scopes) { } private boolean isAllowedToAttachResponseBody(final @NotNull IScopes scopes) { - final @NotNull SentryOptions options = scopes.getOptions(); - return options.isSendDefaultPii() - && !SentryOptions.RequestSize.NONE.equals(options.getMaxRequestBodySize()); + return scopes + .getOptions() + .getDataCollectionResolver() + .isOutgoingResponseBodyWithLegacyBodyGate(); } private void setRequestDetailsOnEvent( diff --git a/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt index 316edf53ff4..3d367663a15 100644 --- a/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt +++ b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt @@ -12,6 +12,7 @@ import graphql.schema.GraphQLObjectType import graphql.schema.GraphQLScalarType import graphql.schema.GraphQLSchema import io.sentry.Hint +import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes import io.sentry.KeyValueCollectionBehavior @@ -253,6 +254,124 @@ class ExceptionReporterTest { ) } + @Test + fun `legacy options can disable outgoing response data`() { + val options = SentryOptions().also { it.maxRequestBodySize = SentryOptions.RequestSize.ALWAYS } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNull(it.contexts.response) }, + any(), + ) + } + + @Test + fun `legacy request body size can disable outgoing response data`() { + val options = SentryOptions().also { it.isSendDefaultPii = true } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNull(it.contexts.response) }, + any(), + ) + } + + @Test + fun `data collection response ignores legacy request body options`() { + val options = + SentryOptions().also { + it.maxRequestBodySize = SentryOptions.RequestSize.NONE + it.dataCollection.httpBodies = setOf(HttpBodyType.OUTGOING_RESPONSE) + } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNotNull(it.contexts.response?.data) }, + any(), + ) + } + + @Test + fun `data collection can disable outgoing response data`() { + val options = fixture.defaultOptions.also { it.dataCollection.httpBodies = emptySet() } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNull(it.contexts.response) }, + any(), + ) + } + + @Test + fun `data collection namespace default enables outgoing response data`() { + val options = + SentryOptions().also { + it.maxRequestBodySize = SentryOptions.RequestSize.NONE + it.dataCollection.cookies = KeyValueCollectionBehavior.off() + } + val exceptionReporter = fixture.getSut(options) + + exceptionReporter.captureThrowable( + fixture.exception, + ExceptionReporter.ExceptionDetails( + fixture.scopes, + fixture.instrumentationExecutionParameters, + false, + ), + fixture.executionResult, + ) + + verify(fixture.scopes) + .captureEvent( + org.mockito.kotlin.check { assertNotNull(it.contexts.response?.data) }, + any(), + ) + } + @Test fun `does not attach query or variables if sendDefaultPii is false`() { val exceptionReporter = diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index db9547700dc..7b65d3d86ce 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -435,6 +435,7 @@ public final class io/sentry/DataCollectionResolver { public fun isIncomingResponseBody ()Z public fun isOutgoingRequestBody ()Z public fun isOutgoingResponseBody ()Z + public fun isOutgoingResponseBodyWithLegacyBodyGate ()Z public fun isUserInfo ()Z } diff --git a/sentry/src/main/java/io/sentry/DataCollectionResolver.java b/sentry/src/main/java/io/sentry/DataCollectionResolver.java index a293614eb2b..16d27b68781 100644 --- a/sentry/src/main/java/io/sentry/DataCollectionResolver.java +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -104,6 +104,10 @@ public boolean isOutgoingResponseBody() { return isHttpBodyEnabled(HttpBodyType.OUTGOING_RESPONSE, options.isSendDefaultPii()); } + public boolean isOutgoingResponseBodyWithLegacyBodyGate() { + return isHttpBodyEnabled(HttpBodyType.OUTGOING_RESPONSE, isLegacyGraphqlBodyEnabled()); + } + private boolean isLegacyGraphqlBodyEnabled() { return options.isSendDefaultPii() && !SentryOptions.RequestSize.NONE.equals(options.getMaxRequestBodySize()); diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt index fb525ce43d2..d84df7ae6a7 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -128,6 +128,37 @@ class DataCollectionResolverTest { assertThat(options.dataCollectionResolver.isGraphqlVariablesWithLegacyBodyGate).isTrue() } + @Test + fun `outgoing response legacy body variant preserves the legacy size gate`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + maxRequestBodySize = SentryOptions.RequestSize.NONE + } + + assertThat(options.dataCollectionResolver.isOutgoingResponseBodyWithLegacyBodyGate).isFalse() + + options.maxRequestBodySize = SentryOptions.RequestSize.SMALL + + assertThat(options.dataCollectionResolver.isOutgoingResponseBodyWithLegacyBodyGate).isTrue() + } + + @Test + fun `outgoing response legacy body variant uses data collection when namespace is explicit`() { + val options = + SentryOptions().apply { + isSendDefaultPii = false + maxRequestBodySize = SentryOptions.RequestSize.NONE + dataCollection.graphql.setDocument(true) + } + + assertThat(options.dataCollectionResolver.isOutgoingResponseBodyWithLegacyBodyGate).isTrue() + + options.dataCollection.httpBodies = emptySet() + + assertThat(options.dataCollectionResolver.isOutgoingResponseBodyWithLegacyBodyGate).isFalse() + } + @Test fun `GraphQL legacy body variants ignore the size option when namespace is explicit`() { val options = From 5806d4e71e2c10e1e02abed0d9d31b14df85071c Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 20 Jul 2026 11:36:47 +0200 Subject: [PATCH 12/63] feat(http): Apply request header collection policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter automatically collected request headers through the Data Collection policy across Servlet, Spring, OpenTelemetry, OkHttp, Ktor, and Apollo integrations. Preserve each integration’s sendDefaultPii behavior when Data Collection is absent. Co-Authored-By: Claude --- .../apollo3/SentryApollo3HttpInterceptor.kt | 17 ++++- .../SentryApollo3InterceptorClientErrors.kt | 36 +++++++++++ .../apollo4/SentryApollo4HttpInterceptor.kt | 17 ++++- ...pollo4BuilderExtensionsClientErrorsTest.kt | 16 +++++ .../ktorClient/SentryKtorClientUtils.kt | 15 ++++- .../ktorClient/SentryKtorClientPluginTest.kt | 51 +++++++++++++++ .../io/sentry/okhttp/SentryOkHttpUtils.kt | 20 +++++- .../io/sentry/okhttp/SentryOkHttpUtilsTest.kt | 37 +++++++++++ .../OpenTelemetryAttributesExtractor.java | 10 ++- .../OpenTelemetryAttributesExtractorTest.kt | 38 ++++++++++++ ...tryRequestHttpServletRequestProcessor.java | 14 ++++- .../jakarta/SentryServletRequestListener.java | 3 +- ...yRequestHttpServletRequestProcessorTest.kt | 54 ++++++++++++++-- ...tryRequestHttpServletRequestProcessor.java | 14 ++++- .../servlet/SentryServletRequestListener.java | 3 +- ...yRequestHttpServletRequestProcessorTest.kt | 52 ++++++++++++++-- .../sentry/spring7/SentryRequestResolver.java | 8 ++- .../webflux/SentryRequestResolver.java | 8 ++- .../sentry/spring7/SentrySpringFilterTest.kt | 25 ++++++++ .../spring/jakarta/SentryRequestResolver.java | 8 ++- .../webflux/SentryRequestResolver.java | 8 ++- .../spring/jakarta/SentrySpringFilterTest.kt | 25 ++++++++ .../sentry/spring/SentryRequestResolver.java | 8 ++- .../spring/webflux/SentryRequestResolver.java | 8 ++- .../sentry/spring/SentrySpringFilterTest.kt | 25 ++++++++ sentry/api/sentry.api | 1 + .../main/java/io/sentry/util/HttpUtils.java | 60 ++++++++++++++++++ .../test/java/io/sentry/util/HttpUtilsTest.kt | 62 +++++++++++++++++++ 28 files changed, 610 insertions(+), 33 deletions(-) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 835c0d75763..54b47900fbb 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -264,6 +264,21 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value + private fun getRequestHeaders(headers: List): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val requestHeaders = mutableMapOf() + for (header in headers) { + requestHeaders[header.name] = header.value + } + return HttpUtils.filterHeaders( + requestHeaders, + scopes.options.dataCollectionResolver.httpRequestHeaders, + ) + .toMutableMap() + } + return getHeaders(headers) + } + private fun getHeaders(headers: List): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { @@ -359,7 +374,7 @@ constructor( cookies = if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null method = request.method.name - headers = getHeaders(request.headers) + headers = getRequestHeaders(request.headers) apiTarget = "graphql" request.body?.let { diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt index 46c93a83136..074333588da 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt @@ -7,6 +7,7 @@ import com.apollographql.apollo3.exception.ApolloException import io.sentry.Hint import io.sentry.HttpBodyType import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS @@ -341,6 +342,41 @@ class SentryApollo3InterceptorClientErrors { ) } + @Test + fun `data collection filters request headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("operation-name") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals( + "[Filtered]", + it.request!!.headers?.get("X-APOLLO-OPERATION-NAME"), + ) + }, + any(), + ) + } + + @Test + fun `data collection can disable request headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.request!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `capture errors with more request context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index afb3c9cba7a..8e7dc10a617 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -263,6 +263,21 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value + private fun getRequestHeaders(headers: List): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val requestHeaders = mutableMapOf() + for (header in headers) { + requestHeaders[header.name] = header.value + } + return HttpUtils.filterHeaders( + requestHeaders, + scopes.options.dataCollectionResolver.httpRequestHeaders, + ) + .toMutableMap() + } + return getHeaders(headers) + } + private fun getHeaders(headers: List): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { @@ -358,7 +373,7 @@ constructor( cookies = if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null method = request.method.name - headers = getHeaders(request.headers) + headers = getRequestHeaders(request.headers) apiTarget = "graphql" request.body?.let { diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt index 625838225ab..abf6b52e7d4 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -10,6 +10,7 @@ import com.apollographql.apollo.exception.ApolloException import io.sentry.Hint import io.sentry.HttpBodyType import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS @@ -355,6 +356,21 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection can disable request headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.request!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `capture errors with more request context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt index b56d3042de4..4398c960687 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt @@ -40,7 +40,7 @@ internal object SentryKtorClientUtils { urlDetails.applyToRequest(this) cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null method = request.method.value - headers = getHeaders(scopes, request.headers) + headers = getRequestHeaders(scopes, request.headers) bodySize = request.content.contentLength } @@ -67,6 +67,19 @@ internal object SentryKtorClientUtils { scopes.captureEvent(event, hint) } + private fun getRequestHeaders(scopes: IScopes, headers: Headers): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val requestHeaders = + headers.toMap().mapValues { (_, values) -> values.joinToString(",") }.toMutableMap() + return HttpUtils.filterHeaders( + requestHeaders, + scopes.options.dataCollectionResolver.httpRequestHeaders, + ) + .toMutableMap() + } + return getHeaders(scopes, headers) + } + private fun getHeaders(scopes: IScopes, headers: Headers): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { diff --git a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt index 976d3200e11..1b5d090a9a8 100644 --- a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt +++ b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt @@ -14,6 +14,7 @@ import io.sentry.Hint import io.sentry.HttpStatusCodeRange import io.sentry.IScope import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.Sentry @@ -255,6 +256,56 @@ class SentryKtorClientPluginTest { verify(fixture.scopes, never()).captureEvent(any(), any()) } + @Test + fun `data collection filters request headers`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + }, + ) + + sut.get(fixture.server.url("/hello").toString()) { + headers["content-type"] = "application/json" + headers["authorization"] = "Bearer token" + headers["x-customer"] = "customer value" + } + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("application/json", it.request!!.headers!!["content-type"]) + assertEquals("[Filtered]", it.request!!.headers!!["authorization"]) + assertEquals("[Filtered]", it.request!!.headers!!["x-customer"]) + }, + any(), + ) + } + + @Test + fun `data collection can disable request headers`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + }, + ) + + sut.get(fixture.server.url("/hello").toString()) { headers["myHeader"] = "myValue" } + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.request!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `does not capture headers when sendDefaultPii is disabled`(): Unit = runBlocking { val sut = diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt index 2750fec4569..fd89ef6e186 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt @@ -40,7 +40,7 @@ internal object SentryOkHttpUtils { // Cookie is only sent if isSendDefaultPii is enabled cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null method = request.method - headers = getHeaders(scopes, request.headers) + headers = getRequestHeaders(scopes, request.headers) request.body?.contentLength().ifHasValidLength { bodySize = it } } @@ -67,6 +67,24 @@ internal object SentryOkHttpUtils { } } + private fun getRequestHeaders( + scopes: IScopes, + requestHeaders: Headers, + ): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val headers = mutableMapOf() + for (i in 0 until requestHeaders.size) { + headers[requestHeaders.name(i)] = requestHeaders.value(i) + } + return HttpUtils.filterHeaders( + headers, + scopes.options.dataCollectionResolver.httpRequestHeaders, + ) + .toMutableMap() + } + return getHeaders(scopes, requestHeaders) + } + private fun getHeaders(scopes: IScopes, requestHeaders: Headers): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt index 0c03d396921..13d10b1d84a 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt @@ -2,6 +2,7 @@ package io.sentry.okhttp import io.sentry.Hint import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.TransactionContext @@ -36,12 +37,14 @@ class SentryOkHttpUtilsTest { responseBody: String = "success", socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, sendDefaultPii: Boolean = false, + configureOptions: SentryOptions.() -> Unit = {}, ): OkHttpClient { val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" setTracePropagationTargets(listOf(server.hostName)) isSendDefaultPii = sendDefaultPii + configureOptions() } whenever(scopes.options).thenReturn(options) @@ -121,6 +124,40 @@ class SentryOkHttpUtilsTest { ) } + @Test + fun `data collection filters request headers`() { + val sut = fixture.getSut { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("myheader") + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("[Filtered]", it.request!!.headers!!["myHeader"]) + assertEquals("[Filtered]", it.request!!.headers!!["Cookie"]) + }, + any(), + ) + } + + @Test + fun `data collection can disable request headers`() { + val sut = fixture.getSut { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent(check { assertTrue(it.request!!.headers!!.isEmpty()) }, any()) + } + @Test fun `captureClientError without sendDefaultPii does not send headers`() { val sut = fixture.getSut(sendDefaultPii = false) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java index 87088ae2377..015e56d7949 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java @@ -77,6 +77,8 @@ private void addRequestAttributesToScope( private static Map collectHeaders( final @NotNull Attributes attributes, final @NotNull SentryOptions options) { Map headers = new HashMap<>(); + final boolean isDataCollectionConfigured = + options.getDataCollectionResolver().isDataCollectionConfigured(); attributes.forEach( (key, value) -> { @@ -84,7 +86,9 @@ private static Map collectHeaders( if (attributeKeyAsString.startsWith(HTTP_REQUEST_HEADER_PREFIX)) { final @NotNull String headerName = StringUtils.removePrefix(attributeKeyAsString, HTTP_REQUEST_HEADER_PREFIX); - if (options.isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { + if (isDataCollectionConfigured + || options.isSendDefaultPii() + || !HttpUtils.containsSensitiveHeader(headerName)) { if (value instanceof List) { try { final @NotNull List headerValues = (List) value; @@ -102,6 +106,10 @@ private static Map collectHeaders( } } }); + if (isDataCollectionConfigured) { + return HttpUtils.filterHeaders( + headers, options.getDataCollectionResolver().getHttpRequestHeaders()); + } return headers; } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt index 6d37240f0b2..01efc74164f 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -6,6 +6,7 @@ import io.opentelemetry.sdk.trace.data.SpanData import io.opentelemetry.semconv.HttpAttributes import io.opentelemetry.semconv.ServerAttributes import io.opentelemetry.semconv.UrlAttributes +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.SentryOptions import io.sentry.protocol.Request @@ -323,6 +324,43 @@ class OpenTelemetryAttributesExtractorTest { thenHeaderIsNotPresentOnRequest("some-header") } + @Test + fun `data collection filters request header attributes`() { + fixture.options.dataCollection.httpHeaders.request = + KeyValueCollectionBehavior.denyList("customer") + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + AttributeKey.stringArrayKey("http.request.header.content-type") to + listOf("application/json"), + AttributeKey.stringArrayKey("http.request.header.authorization") to listOf("Bearer token"), + AttributeKey.stringArrayKey("http.request.header.x-customer") to listOf("customer value"), + ) + ) + + whenExtractingAttributes() + + thenHeaderIsPresentOnRequest("content-type", "application/json") + thenHeaderIsPresentOnRequest("authorization", "[Filtered]") + thenHeaderIsPresentOnRequest("x-customer", "[Filtered]") + } + + @Test + fun `data collection can disable request header attributes`() { + fixture.options.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + AttributeKey.stringArrayKey("http.request.header.content-type") to + listOf("application/json"), + ) + ) + + whenExtractingAttributes() + + assertNull(fixture.scope.request!!.headers) + } + @Test fun `if there are no header attributes does not set headers on request`() { givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) diff --git a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java index 1ee536cb926..777dd13c037 100644 --- a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java @@ -3,6 +3,7 @@ import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; +import io.sentry.SentryOptions; import io.sentry.protocol.Request; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; @@ -20,9 +21,12 @@ final class SentryRequestHttpServletRequestProcessor implements EventProcessor { private final @NotNull HttpServletRequest httpRequest; + private final @NotNull SentryOptions options; - public SentryRequestHttpServletRequestProcessor(@NotNull HttpServletRequest httpRequest) { + public SentryRequestHttpServletRequestProcessor( + @NotNull HttpServletRequest httpRequest, @NotNull SentryOptions options) { this.httpRequest = Objects.requireNonNull(httpRequest, "httpRequest is required"); + this.options = Objects.requireNonNull(options, "options are required"); } // httpRequest.getRequestURL() returns StringBuffer which is considered an obsolete class. @@ -45,11 +49,15 @@ public SentryRequestHttpServletRequestProcessor(@NotNull HttpServletRequest http final @NotNull HttpServletRequest request) { final Map headersMap = new HashMap<>(); for (String headerName : Collections.list(request.getHeaderNames())) { - // do not copy personal information identifiable headers - if (!HttpUtils.containsSensitiveHeader(headerName.toUpperCase(Locale.ROOT))) { + if (options.getDataCollectionResolver().isDataCollectionConfigured() + || !HttpUtils.containsSensitiveHeader(headerName.toUpperCase(Locale.ROOT))) { headersMap.put(headerName, toString(request.getHeaders(headerName))); } } + if (options.getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, options.getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryServletRequestListener.java b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryServletRequestListener.java index 9c8edeaf71c..909aee9b003 100644 --- a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryServletRequestListener.java +++ b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryServletRequestListener.java @@ -59,7 +59,8 @@ public void requestInitialized(@NotNull ServletRequestEvent servletRequestEvent) scopes.configureScope( scope -> { - scope.addEventProcessor(new SentryRequestHttpServletRequestProcessor(httpRequest)); + scope.addEventProcessor( + new SentryRequestHttpServletRequestProcessor(httpRequest, scopes.getOptions())); }); } } diff --git a/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt index 3e420aa1dfb..0aa9228530e 100644 --- a/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt @@ -1,6 +1,7 @@ package io.sentry.servlet.jakarta import io.sentry.Hint +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryEvent import io.sentry.SentryOptions import jakarta.servlet.http.HttpServletRequest @@ -24,7 +25,7 @@ class SentryRequestHttpServletRequestProcessorTest { url = "http://example.com?param1=xyz", headers = mapOf("some-header" to "some-header value", "Accept" to "application/json"), ) - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, SentryOptions()) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -47,7 +48,7 @@ class SentryRequestHttpServletRequestProcessorTest { url = "http://example.com?param1=xyz", headers = mapOf("another-header" to listOf("another value", "another value2")), ) - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, SentryOptions()) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -63,7 +64,7 @@ class SentryRequestHttpServletRequestProcessorTest { mockRequest(url = "http://example.com?param1=xyz", headers = mapOf("Cookie" to "name=value")) val sentryOptions = SentryOptions() sentryOptions.isSendDefaultPii = false - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, sentryOptions) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -71,6 +72,51 @@ class SentryRequestHttpServletRequestProcessorTest { assertNotNull(event.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters request headers`() { + val request = + mockRequest( + url = "http://example.com", + headers = + mapOf( + "content-type" to "application/json", + "authorization" to "Bearer token", + "x-customer" to "customer value", + ), + ) + val options = + SentryOptions().also { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertEquals( + mapOf( + "content-type" to "application/json", + "authorization" to "[Filtered]", + "x-customer" to "[Filtered]", + ), + event.request!!.headers, + ) + } + + @Test + fun `data collection can disable request headers`() { + val request = + mockRequest(url = "http://example.com", headers = mapOf("content-type" to "application/json")) + val options = + SentryOptions().also { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertEquals(emptyMap(), event.request!!.headers) + } + @Test fun `does not attach sensitive headers`() { val request = @@ -87,7 +133,7 @@ class SentryRequestHttpServletRequestProcessorTest { ) val sentryOptions = SentryOptions() sentryOptions.isSendDefaultPii = false - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, sentryOptions) val event = SentryEvent() eventProcessor.process(event, Hint()) diff --git a/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java b/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java index a005d50c0ad..2034ab3c75d 100644 --- a/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java @@ -3,6 +3,7 @@ import io.sentry.EventProcessor; import io.sentry.Hint; import io.sentry.SentryEvent; +import io.sentry.SentryOptions; import io.sentry.protocol.Request; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; @@ -20,9 +21,12 @@ final class SentryRequestHttpServletRequestProcessor implements EventProcessor { private final @NotNull HttpServletRequest httpRequest; + private final @NotNull SentryOptions options; - public SentryRequestHttpServletRequestProcessor(@NotNull HttpServletRequest httpRequest) { + public SentryRequestHttpServletRequestProcessor( + @NotNull HttpServletRequest httpRequest, @NotNull SentryOptions options) { this.httpRequest = Objects.requireNonNull(httpRequest, "httpRequest is required"); + this.options = Objects.requireNonNull(options, "options are required"); } // httpRequest.getRequestURL() returns StringBuffer which is considered an obsolete class. @@ -45,11 +49,15 @@ public SentryRequestHttpServletRequestProcessor(@NotNull HttpServletRequest http final @NotNull HttpServletRequest request) { final Map headersMap = new HashMap<>(); for (String headerName : Collections.list(request.getHeaderNames())) { - // do not copy personal information identifiable headers - if (!HttpUtils.containsSensitiveHeader(headerName.toUpperCase(Locale.ROOT))) { + if (options.getDataCollectionResolver().isDataCollectionConfigured() + || !HttpUtils.containsSensitiveHeader(headerName.toUpperCase(Locale.ROOT))) { headersMap.put(headerName, toString(request.getHeaders(headerName))); } } + if (options.getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, options.getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-servlet/src/main/java/io/sentry/servlet/SentryServletRequestListener.java b/sentry-servlet/src/main/java/io/sentry/servlet/SentryServletRequestListener.java index 0a2a2f5d230..1874cacc66b 100644 --- a/sentry-servlet/src/main/java/io/sentry/servlet/SentryServletRequestListener.java +++ b/sentry-servlet/src/main/java/io/sentry/servlet/SentryServletRequestListener.java @@ -59,7 +59,8 @@ public void requestInitialized(@NotNull ServletRequestEvent servletRequestEvent) scopes.configureScope( scope -> { - scope.addEventProcessor(new SentryRequestHttpServletRequestProcessor(httpRequest)); + scope.addEventProcessor( + new SentryRequestHttpServletRequestProcessor(httpRequest, scopes.getOptions())); }); } } diff --git a/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt index a42a8ebb39b..48be73bdc53 100644 --- a/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt @@ -1,6 +1,7 @@ package io.sentry.servlet import io.sentry.Hint +import io.sentry.KeyValueCollectionBehavior import io.sentry.SentryEvent import io.sentry.SentryOptions import java.net.URI @@ -21,7 +22,7 @@ class SentryRequestHttpServletRequestProcessorTest { .header("some-header", "some-header value") .accept("application/json") .buildRequest(MockServletContext()) - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, SentryOptions()) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -44,7 +45,7 @@ class SentryRequestHttpServletRequestProcessorTest { .header("another-header", "another value") .header("another-header", "another value2") .buildRequest(MockServletContext()) - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, SentryOptions()) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -62,7 +63,7 @@ class SentryRequestHttpServletRequestProcessorTest { .buildRequest(MockServletContext()) val sentryOptions = SentryOptions() sentryOptions.isSendDefaultPii = false - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, sentryOptions) val event = SentryEvent() eventProcessor.process(event, Hint()) @@ -70,6 +71,49 @@ class SentryRequestHttpServletRequestProcessorTest { assertNotNull(event.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters request headers`() { + val request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("content-type", "application/json") + .header("authorization", "Bearer token") + .header("x-customer", "customer value") + .buildRequest(MockServletContext()) + val options = + SentryOptions().also { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertEquals( + mapOf( + "Content-Type" to "application/json", + "authorization" to "[Filtered]", + "x-customer" to "[Filtered]", + ), + event.request!!.headers, + ) + } + + @Test + fun `data collection can disable request headers`() { + val request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("content-type", "application/json") + .buildRequest(MockServletContext()) + val options = + SentryOptions().also { + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertEquals(emptyMap(), event.request!!.headers) + } + @Test fun `does not attach sensitive headers`() { val request = @@ -82,7 +126,7 @@ class SentryRequestHttpServletRequestProcessorTest { .buildRequest(MockServletContext()) val sentryOptions = SentryOptions() sentryOptions.isSendDefaultPii = false - val eventProcessor = SentryRequestHttpServletRequestProcessor(request) + val eventProcessor = SentryRequestHttpServletRequestProcessor(request, sentryOptions) val event = SentryEvent() eventProcessor.process(event, Hint()) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java index aba0ae808d1..abc809933b0 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java @@ -60,8 +60,8 @@ Map resolveHeadersMap( final @NotNull List additionalSecurityCookieNames) { final Map headersMap = new HashMap<>(); for (String headerName : Collections.list(request.getHeaderNames())) { - // do not copy personal information identifiable headers - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { final @Nullable List filteredHeaders = HttpUtils.filterOutSecurityCookiesFromHeader( @@ -69,6 +69,10 @@ Map resolveHeadersMap( headersMap.put(headerName, toString(filteredHeaders)); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java index 3d6857cb648..229ab887665 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java @@ -50,9 +50,9 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { Map resolveHeadersMap(final HttpHeaders request) { final Map headersMap = new HashMap<>(); for (Map.Entry> entry : request.headerSet()) { - // do not copy personal information identifiable headers String headerName = entry.getKey(); - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { headersMap.put( headerName, @@ -61,6 +61,10 @@ Map resolveHeadersMap(final HttpHeaders request) { entry.getValue(), headerName, Collections.emptyList()))); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt index 532b3c686b2..b1ea92766b1 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt @@ -5,6 +5,7 @@ import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions @@ -204,6 +205,30 @@ class SentrySpringFilterTest { assertNotNull(fixture.scope.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters request headers`() { + val sentryOptions = + SentryOptions().apply { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("content-type", "application/json") + .header("authorization", "Bearer token") + .header("x-customer", "customer value") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertEquals("application/json", fixture.scope.request!!.headers!!["Content-Type"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["authorization"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["x-customer"]) + } + @Test fun `when sendDefaultPii is set to false, does not attach sensitive headers`() { val sentryOptions = SentryOptions().apply { isSendDefaultPii = false } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java index 4bb2ad312bb..857027f70d3 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java @@ -60,8 +60,8 @@ Map resolveHeadersMap( final @NotNull List additionalSecurityCookieNames) { final Map headersMap = new HashMap<>(); for (String headerName : Collections.list(request.getHeaderNames())) { - // do not copy personal information identifiable headers - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { final @Nullable List filteredHeaders = HttpUtils.filterOutSecurityCookiesFromHeader( @@ -69,6 +69,10 @@ Map resolveHeadersMap( headersMap.put(headerName, toString(filteredHeaders)); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java index d58291ade6e..a78a329729b 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java @@ -50,9 +50,9 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { Map resolveHeadersMap(final HttpHeaders request) { final Map headersMap = new HashMap<>(); for (Map.Entry> entry : request.entrySet()) { - // do not copy personal information identifiable headers String headerName = entry.getKey(); - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { headersMap.put( headerName, @@ -61,6 +61,10 @@ Map resolveHeadersMap(final HttpHeaders request) { entry.getValue(), headerName, Collections.emptyList()))); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt index ad6c01e99d1..f3c94cd4500 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt @@ -5,6 +5,7 @@ import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions @@ -204,6 +205,30 @@ class SentrySpringFilterTest { assertNotNull(fixture.scope.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters request headers`() { + val sentryOptions = + SentryOptions().apply { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("content-type", "application/json") + .header("authorization", "Bearer token") + .header("x-customer", "customer value") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertEquals("application/json", fixture.scope.request!!.headers!!["Content-Type"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["authorization"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["x-customer"]) + } + @Test fun `when sendDefaultPii is set to false, does not attach sensitive headers`() { val sentryOptions = SentryOptions().apply { isSendDefaultPii = false } diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java index 56294fda083..6e71d22b902 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java @@ -60,8 +60,8 @@ Map resolveHeadersMap( final @NotNull List additionalSecurityCookieNames) { final Map headersMap = new HashMap<>(); for (String headerName : Collections.list(request.getHeaderNames())) { - // do not copy personal information identifiable headers - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { final @Nullable List filteredHeaders = HttpUtils.filterOutSecurityCookiesFromHeader( @@ -69,6 +69,10 @@ Map resolveHeadersMap( headersMap.put(headerName, toString(filteredHeaders)); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java index 76e50985e53..5e0c1a9b724 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java @@ -50,9 +50,9 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { Map resolveHeadersMap(final HttpHeaders request) { final Map headersMap = new HashMap<>(); for (Map.Entry> entry : request.entrySet()) { - // do not copy personal information identifiable headers String headerName = entry.getKey(); - if (scopes.getOptions().isSendDefaultPii() + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { headersMap.put( headerName, @@ -61,6 +61,10 @@ Map resolveHeadersMap(final HttpHeaders request) { entry.getValue(), headerName, Collections.emptyList()))); } } + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + return HttpUtils.filterHeaders( + headersMap, scopes.getOptions().getDataCollectionResolver().getHttpRequestHeaders()); + } return headersMap; } diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt index cfc5042dc58..b33ad7731d5 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt @@ -5,6 +5,7 @@ import io.sentry.HttpBodyType import io.sentry.IScope import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions @@ -204,6 +205,30 @@ class SentrySpringFilterTest { assertNotNull(fixture.scope.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters request headers`() { + val sentryOptions = + SentryOptions().apply { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("customer") + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("content-type", "application/json") + .header("authorization", "Bearer token") + .header("x-customer", "customer value") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertEquals("application/json", fixture.scope.request!!.headers!!["Content-Type"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["authorization"]) + assertEquals("[Filtered]", fixture.scope.request!!.headers!!["x-customer"]) + } + @Test fun `when sendDefaultPii is set to false, does not attach sensitive headers`() { val sentryOptions = SentryOptions().apply { isSendDefaultPii = false } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 7b65d3d86ce..0f8eeaab1b0 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7807,6 +7807,7 @@ public final class io/sentry/util/HttpUtils { public static final field COOKIE_HEADER_NAME Ljava/lang/String; public fun ()V public static fun containsSensitiveHeader (Ljava/lang/String;)Z + public static fun filterHeaders (Ljava/util/Map;Lio/sentry/KeyValueCollectionBehavior;)Ljava/util/Map; public static fun filterOutSecurityCookies (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String; public static fun filterOutSecurityCookiesFromHeader (Ljava/util/Enumeration;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; public static fun filterOutSecurityCookiesFromHeader (Ljava/util/List;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index 399ba7013fe..fba50e2bd15 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -3,12 +3,15 @@ import static io.sentry.util.UrlUtils.SENSITIVE_DATA_SUBSTITUTE; import io.sentry.HttpStatusCodeRange; +import io.sentry.KeyValueCollectionBehavior; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Enumeration; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -33,6 +36,26 @@ public final class HttpUtils { "X-CSRFTOKEN", "X-XSRF-TOKEN"); + private static final List SENSITIVE_DATA_KEYS = + Arrays.asList( + "auth", + "token", + "secret", + "password", + "passwd", + "pwd", + "key", + "jwt", + "bearer", + "sso", + "saml", + "csrf", + "xsrf", + "credentials", + "session", + "sid", + "identity"); + private static final List SECURITY_COOKIES = Arrays.asList( "JSESSIONID", @@ -53,6 +76,43 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return SENSITIVE_HEADERS.contains(header.toUpperCase(Locale.ROOT)); } + public static @NotNull Map filterHeaders( + final @NotNull Map headers, + final @NotNull KeyValueCollectionBehavior behavior) { + final @NotNull Map filteredHeaders = new LinkedHashMap<>(); + if (behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return filteredHeaders; + } + + for (final Map.Entry header : headers.entrySet()) { + final @NotNull String name = header.getKey(); + final boolean sensitive = + containsTerm(name, SENSITIVE_DATA_KEYS) + || "Cookie".equalsIgnoreCase(name) + || "Set-Cookie".equalsIgnoreCase(name); + final boolean matchesTerm = containsTerm(name, behavior.getTerms()); + final boolean shouldFilter = + sensitive + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST && matchesTerm) + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST && !matchesTerm); + filteredHeaders.put(name, shouldFilter ? SENSITIVE_DATA_SUBSTITUTE : header.getValue()); + } + return filteredHeaders; + } + + private static boolean containsTerm( + final @NotNull String key, final @NotNull List terms) { + final @NotNull String normalizedKey = key.toLowerCase(Locale.ROOT); + for (final String term : terms) { + if (term != null + && !term.isEmpty() + && normalizedKey.contains(term.toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } + public static @Nullable List filterOutSecurityCookiesFromHeader( final @Nullable Enumeration headers, final @Nullable String headerName, diff --git a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt index 6d7815888e5..1e9ed10f806 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -1,5 +1,7 @@ package io.sentry.util +import com.google.common.truth.Truth.assertThat +import io.sentry.KeyValueCollectionBehavior import java.util.Enumeration import java.util.StringTokenizer import kotlin.test.Test @@ -8,6 +10,66 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull class HttpUtilsTest { + @Test + fun `header filter disables collection in off mode`() { + val filtered = + HttpUtils.filterHeaders( + mapOf("content-type" to "application/json"), + KeyValueCollectionBehavior.off(), + ) + + assertThat(filtered).isEmpty() + } + + @Test + fun `header deny list filters built-in sensitive and configured terms`() { + val filtered = + HttpUtils.filterHeaders( + mapOf( + "content-type" to "application/json", + "authorization" to "Bearer token", + "x-customer" to "customer value", + "Cookie" to "name=value", + ), + KeyValueCollectionBehavior.denyList("customer"), + ) + + assertThat(filtered) + .containsExactly( + "content-type", + "application/json", + "authorization", + "[Filtered]", + "x-customer", + "[Filtered]", + "Cookie", + "[Filtered]", + ) + } + + @Test + fun `header allow list only retains allowed non-sensitive values`() { + val filtered = + HttpUtils.filterHeaders( + mapOf( + "content-type" to "application/json", + "authorization" to "Bearer token", + "x-customer" to "customer value", + ), + KeyValueCollectionBehavior.allowList("content", "authorization"), + ) + + assertThat(filtered) + .containsExactly( + "content-type", + "application/json", + "authorization", + "[Filtered]", + "x-customer", + "[Filtered]", + ) + } + @Test fun `null enumeration returns null when filtering security cookies from headers`() { val enumeration: Enumeration? = null From 09e1448dc3df186a5e1e8479e643b3aa0569088a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 20 Jul 2026 11:55:18 +0200 Subject: [PATCH 13/63] perf(core): Skip redundant header term matching Avoid evaluating custom allow or deny terms after a request header has already matched the built-in sensitive policy. Co-Authored-By: Claude --- .../src/main/java/io/sentry/util/HttpUtils.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index fba50e2bd15..d6b9072284e 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -90,12 +90,16 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { containsTerm(name, SENSITIVE_DATA_KEYS) || "Cookie".equalsIgnoreCase(name) || "Set-Cookie".equalsIgnoreCase(name); - final boolean matchesTerm = containsTerm(name, behavior.getTerms()); - final boolean shouldFilter = - sensitive - || (behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST && matchesTerm) - || (behavior.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST && !matchesTerm); - filteredHeaders.put(name, shouldFilter ? SENSITIVE_DATA_SUBSTITUTE : header.getValue()); + if (sensitive) { + filteredHeaders.put(name, SENSITIVE_DATA_SUBSTITUTE); + } else { + final boolean matchesTerm = containsTerm(name, behavior.getTerms()); + final boolean shouldFilter = + behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST + ? matchesTerm + : !matchesTerm; + filteredHeaders.put(name, shouldFilter ? SENSITIVE_DATA_SUBSTITUTE : header.getValue()); + } } return filteredHeaders; } From 55533910de75781d3a36098c989db1c5e2f35850 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 20 Jul 2026 13:01:58 +0200 Subject: [PATCH 14/63] feat(http): Apply response header collection policy Filter automatically collected response headers through the Data Collection policy in OkHttp, Ktor, and Apollo failed-request events. Preserve sendDefaultPii behavior when Data Collection is absent. Co-Authored-By: Claude --- .../apollo3/SentryApollo3HttpInterceptor.kt | 17 +++++- .../SentryApollo3InterceptorClientErrors.kt | 32 +++++++++++ .../apollo4/SentryApollo4HttpInterceptor.kt | 17 +++++- ...pollo4BuilderExtensionsClientErrorsTest.kt | 15 ++++++ .../ktorClient/SentryKtorClientUtils.kt | 15 +++++- .../ktorClient/SentryKtorClientPluginTest.kt | 53 +++++++++++++++++++ .../io/sentry/okhttp/SentryOkHttpUtils.kt | 20 ++++++- .../io/sentry/okhttp/SentryOkHttpUtilsTest.kt | 34 ++++++++++++ 8 files changed, 199 insertions(+), 4 deletions(-) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 54b47900fbb..a13bc952829 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -279,6 +279,21 @@ constructor( return getHeaders(headers) } + private fun getResponseHeaders(headers: List): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val responseHeaders = mutableMapOf() + for (header in headers) { + responseHeaders[header.name] = header.value + } + return HttpUtils.filterHeaders( + responseHeaders, + scopes.options.dataCollectionResolver.httpResponseHeaders, + ) + .toMutableMap() + } + return getHeaders(headers) + } + private fun getHeaders(headers: List): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { @@ -405,7 +420,7 @@ constructor( } else { null } - headers = getHeaders(response.headers) + headers = getResponseHeaders(response.headers) statusCode = response.statusCode response.body?.buffer?.size?.ifHasValidLength { contentLength -> diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt index 074333588da..d2294f45bb0 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt @@ -435,6 +435,38 @@ class SentryApollo3InterceptorClientErrors { ) } + @Test + fun `data collection filters response headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.denyList("content-length") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("[Filtered]", it.contexts.response!!.headers?.get("Content-Length")) + }, + any(), + ) + } + + @Test + fun `data collection can disable response headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.contexts.response!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `capture errors with more response context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index 8e7dc10a617..28fb646c31c 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -278,6 +278,21 @@ constructor( return getHeaders(headers) } + private fun getResponseHeaders(headers: List): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val responseHeaders = mutableMapOf() + for (header in headers) { + responseHeaders[header.name] = header.value + } + return HttpUtils.filterHeaders( + responseHeaders, + scopes.options.dataCollectionResolver.httpResponseHeaders, + ) + .toMutableMap() + } + return getHeaders(headers) + } + private fun getHeaders(headers: List): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { @@ -404,7 +419,7 @@ constructor( } else { null } - headers = getHeaders(response.headers) + headers = getResponseHeaders(response.headers) statusCode = response.statusCode response.body?.buffer?.size?.ifHasValidLength { contentLength -> diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt index abf6b52e7d4..21bb0dc1b72 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -428,6 +428,21 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection can disable response headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.contexts.response!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `capture errors with more response context if sendDefaultPii is enabled`() { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt index 4398c960687..1a569012558 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt @@ -48,7 +48,7 @@ internal object SentryKtorClientUtils { io.sentry.protocol.Response().apply { // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII cookies = if (scopes.options.isSendDefaultPii) response.headers["Set-Cookie"] else null - headers = getHeaders(scopes, response.headers) + headers = getResponseHeaders(scopes, response.headers) statusCode = response.status.value try { bodySize = response.bodyAsBytes().size.toLong() @@ -80,6 +80,19 @@ internal object SentryKtorClientUtils { return getHeaders(scopes, headers) } + private fun getResponseHeaders(scopes: IScopes, headers: Headers): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val responseHeaders = + headers.toMap().mapValues { (_, values) -> values.joinToString(",") }.toMutableMap() + return HttpUtils.filterHeaders( + responseHeaders, + scopes.options.dataCollectionResolver.httpResponseHeaders, + ) + .toMutableMap() + } + return getHeaders(scopes, headers) + } + private fun getHeaders(scopes: IScopes, headers: Headers): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { diff --git a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt index 1b5d090a9a8..eab2562fba4 100644 --- a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt +++ b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt @@ -306,6 +306,59 @@ class SentryKtorClientPluginTest { ) } + @Test + fun `data collection filters response headers`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.httpHeaders.response = KeyValueCollectionBehavior.denyList("response") + }, + ) + + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals( + "[Filtered]", + it.contexts.response!! + .headers!! + .entries + .firstOrNull { header -> + header.key.equals("myResponseHeader", ignoreCase = true) + } + ?.value, + ) + }, + any(), + ) + } + + @Test + fun `data collection can disable response headers`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + }, + ) + + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes) + .captureEvent( + check { assertTrue(it.contexts.response!!.headers!!.isEmpty()) }, + any(), + ) + } + @Test fun `does not capture headers when sendDefaultPii is disabled`(): Unit = runBlocking { val sut = diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt index fd89ef6e186..07fcac12f06 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt @@ -49,7 +49,7 @@ internal object SentryOkHttpUtils { io.sentry.protocol.Response().apply { // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII cookies = if (scopes.options.isSendDefaultPii) response.headers["Set-Cookie"] else null - headers = getHeaders(scopes, response.headers) + headers = getResponseHeaders(scopes, response.headers) statusCode = response.code response.body?.contentLength().ifHasValidLength { bodySize = it } @@ -85,6 +85,24 @@ internal object SentryOkHttpUtils { return getHeaders(scopes, requestHeaders) } + private fun getResponseHeaders( + scopes: IScopes, + responseHeaders: Headers, + ): MutableMap? { + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + val headers = mutableMapOf() + for (i in 0 until responseHeaders.size) { + headers[responseHeaders.name(i)] = responseHeaders.value(i) + } + return HttpUtils.filterHeaders( + headers, + scopes.options.dataCollectionResolver.httpResponseHeaders, + ) + .toMutableMap() + } + return getHeaders(scopes, responseHeaders) + } + private fun getHeaders(scopes: IScopes, requestHeaders: Headers): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII if (!scopes.options.isSendDefaultPii) { diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt index 13d10b1d84a..d29e8df8260 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt @@ -158,6 +158,40 @@ class SentryOkHttpUtilsTest { .captureEvent(check { assertTrue(it.request!!.headers!!.isEmpty()) }, any()) } + @Test + fun `data collection filters response headers`() { + val sut = fixture.getSut { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.denyList("response") + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("[Filtered]", it.contexts.response!!.headers!!["myResponseHeader"]) + assertEquals("[Filtered]", it.contexts.response!!.headers!!["Set-Cookie"]) + }, + any(), + ) + } + + @Test + fun `data collection can disable response headers`() { + val sut = fixture.getSut { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent(check { assertTrue(it.contexts.response!!.headers!!.isEmpty()) }, any()) + } + @Test fun `captureClientError without sendDefaultPii does not send headers`() { val sut = fixture.getSut(sendDefaultPii = false) From 12d510236c5c4a19001b9139d26a9c06e2c03584 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 21 Jul 2026 15:14:53 +0200 Subject: [PATCH 15/63] ref(core): Remove unused queue collection option Remove the queue option from the initial Data Collection API because the Java SDK does not collect queue payload data that it could control. Co-Authored-By: Claude --- sentry/api/sentry.api | 2 -- sentry/src/main/java/io/sentry/DataCollection.java | 10 ---------- sentry/src/test/java/io/sentry/DataCollectionTest.kt | 11 ----------- 3 files changed, 23 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 22c4636acff..e1b723ac462 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -391,13 +391,11 @@ public final class io/sentry/DataCollection { public fun getHttpBodies ()Ljava/util/Set; public fun getHttpHeaders ()Lio/sentry/DataCollection$HttpHeaders; public fun getQueryParams ()Lio/sentry/KeyValueCollectionBehavior; - public fun getQueues ()Ljava/lang/Boolean; public fun getUserInfo ()Ljava/lang/Boolean; public fun setCookies (Lio/sentry/KeyValueCollectionBehavior;)V public fun setDatabaseQueryData (Z)V public fun setHttpBodies (Ljava/util/Set;)V public fun setQueryParams (Lio/sentry/KeyValueCollectionBehavior;)V - public fun setQueues (Z)V public fun setUserInfo (Z)V } diff --git a/sentry/src/main/java/io/sentry/DataCollection.java b/sentry/src/main/java/io/sentry/DataCollection.java index c1882938068..d46d2d262a8 100644 --- a/sentry/src/main/java/io/sentry/DataCollection.java +++ b/sentry/src/main/java/io/sentry/DataCollection.java @@ -16,7 +16,6 @@ public final class DataCollection { private @Nullable KeyValueCollectionBehavior queryParams; private @Nullable Set httpBodies; private @Nullable Boolean databaseQueryData; - private @Nullable Boolean queues; private final @NotNull HttpHeaders httpHeaders = new HttpHeaders(); private final @NotNull Graphql graphql = new Graphql(); @@ -73,14 +72,6 @@ public void setDatabaseQueryData(final boolean databaseQueryData) { this.databaseQueryData = databaseQueryData; } - public @Nullable Boolean getQueues() { - return queues; - } - - public void setQueues(final boolean queues) { - this.queues = queues; - } - public @NotNull HttpHeaders getHttpHeaders() { return httpHeaders; } @@ -97,7 +88,6 @@ boolean isExplicitlyConfigured() { || queryParams != null || httpBodies != null || databaseQueryData != null - || queues != null || httpHeaders.hasOverrides() || graphql.hasOverrides(); } diff --git a/sentry/src/test/java/io/sentry/DataCollectionTest.kt b/sentry/src/test/java/io/sentry/DataCollectionTest.kt index 8bc9c7af7ae..74cc4b8222c 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionTest.kt @@ -14,7 +14,6 @@ class DataCollectionTest { assertThat(dataCollection.queryParams).isNull() assertThat(dataCollection.httpBodies).isNull() assertThat(dataCollection.databaseQueryData).isNull() - assertThat(dataCollection.queues).isNull() assertThat(dataCollection.httpHeaders.request).isNull() assertThat(dataCollection.httpHeaders.response).isNull() assertThat(dataCollection.graphql.document).isNull() @@ -82,16 +81,6 @@ class DataCollectionTest { assertThat(dataCollection.isExplicitlyConfigured()).isTrue() } - @Test - fun `queues false is distinct from unset`() { - val dataCollection = DataCollection(false) - - dataCollection.setQueues(false) - - assertThat(dataCollection.queues).isFalse() - assertThat(dataCollection.isExplicitlyConfigured()).isTrue() - } - @Test fun `nested HTTP header override marks configuration explicit`() { val dataCollection = DataCollection(false) From 02bb4268f3f0be7dd1b2ca0c16e0a90c31719dcb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 21 Jul 2026 15:15:27 +0200 Subject: [PATCH 16/63] test(core): Update Data Collection replacement coverage Use the supported user information option to verify that SentryOptions replaces its Data Collection instance. Co-Authored-By: Claude --- sentry/src/test/java/io/sentry/SentryOptionsTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 3a43481cd03..57fa9f507a6 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -59,12 +59,12 @@ class SentryOptionsTest { @Test fun `setting data collection replaces the default instance`() { val options = SentryOptions() - val dataCollection = DataCollection().apply { setQueues(false) } + val dataCollection = DataCollection().apply { setUserInfo(false) } options.dataCollection = dataCollection assertThat(options.dataCollection).isSameInstanceAs(dataCollection) - assertThat(options.dataCollection.queues).isFalse() + assertThat(options.dataCollection.userInfo).isFalse() } @Test From d9a142240c4812f6a13e420b1172b52fd9eac1ef Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 22 Jul 2026 07:24:15 +0200 Subject: [PATCH 17/63] feat(http): Apply query parameter collection policy Filter automatically collected URL query parameters according to Data Collection settings across server, client, tracing, breadcrumb, and failed-request integrations. Preserve raw query values when Data Collection is absent and always filter built-in sensitive parameter names in explicit mode. Refs #5666 Co-Authored-By: Claude --- .../apollo3/SentryApollo3HttpInterceptor.kt | 12 +++- .../apollo4/SentryApollo4HttpInterceptor.kt | 12 +++- .../sentry/apollo/SentryApolloInterceptor.kt | 7 +- .../ktorClient/SentryKtorClientUtils.kt | 9 ++- .../io/sentry/okhttp/SentryOkHttpEvent.kt | 8 +-- .../sentry/okhttp/SentryOkHttpInterceptor.kt | 10 ++- .../io/sentry/okhttp/SentryOkHttpUtils.kt | 2 +- .../okhttp/SentryOkHttpInterceptorTest.kt | 20 ++++++ .../sentry/openfeign/SentryFeignClient.java | 6 +- .../OpenTelemetryAttributesExtractor.java | 6 +- .../OpenTelemetryAttributesExtractorTest.kt | 30 +++++++++ ...tryRequestHttpServletRequestProcessor.java | 6 +- ...tryRequestHttpServletRequestProcessor.java | 6 +- ...yRequestHttpServletRequestProcessorTest.kt | 27 ++++++++ .../sentry/spring7/SentryRequestResolver.java | 8 ++- ...entrySpanClientHttpRequestInterceptor.java | 10 ++- .../SentrySpanClientWebRequestFilter.java | 16 ++++- .../webflux/AbstractSentryWebFilter.java | 8 ++- .../webflux/SentryRequestResolver.java | 3 +- .../webflux/SentryWebFluxTracingFilterTest.kt | 2 +- .../spring/jakarta/SentryRequestResolver.java | 8 ++- ...entrySpanClientHttpRequestInterceptor.java | 10 ++- .../SentrySpanClientWebRequestFilter.java | 16 ++++- .../webflux/AbstractSentryWebFilter.java | 8 ++- .../webflux/SentryRequestResolver.java | 3 +- .../webflux/SentryWebFluxTracingFilterTest.kt | 2 +- .../sentry/spring/SentryRequestResolver.java | 8 ++- ...entrySpanClientHttpRequestInterceptor.java | 10 ++- .../SentrySpanClientWebRequestFilter.java | 6 +- .../spring/webflux/SentryRequestResolver.java | 3 +- .../spring/webflux/SentryWebFilter.java | 9 ++- .../webflux/SentryWebFluxTracingFilterTest.kt | 2 +- sentry/api/sentry.api | 4 ++ .../src/main/java/io/sentry/Breadcrumb.java | 25 ++++++- .../main/java/io/sentry/util/HttpUtils.java | 43 ++++++++++++ .../main/java/io/sentry/util/UrlUtils.java | 17 ++++- .../test/java/io/sentry/util/HttpUtilsTest.kt | 49 ++++++++++++++ .../test/java/io/sentry/util/UrlUtilsTest.kt | 66 +++++++++++++++++++ 38 files changed, 442 insertions(+), 55 deletions(-) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index a13bc952829..0f322481e69 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -160,7 +160,7 @@ constructor( operationType: String?, operationId: String?, ): ISpan { - val urlDetails = UrlUtils.parse(request.url) + val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) val method = request.method.name val operation = if (operationType != null) "http.graphql.$operationType" else "http.graphql" @@ -232,7 +232,13 @@ constructor( span.finish() } - val breadcrumb = Breadcrumb.http(request.url, request.method.name, statusCode) + val breadcrumb = + Breadcrumb.http( + request.url, + request.method.name, + statusCode, + scopes.options.dataCollectionResolver, + ) request.body?.contentLength.ifHasValidLength { contentLength -> breadcrumb.setData("request_body_size", contentLength) @@ -351,7 +357,7 @@ constructor( // url will be: https://api.github.com/users/getsentry/repos/ // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ // but that's not possible - val urlDetails = UrlUtils.parse(request.url) + val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) // return if its not a target match if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index 28fb646c31c..0a16c669914 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -159,7 +159,7 @@ constructor( operationType: String?, operationId: String?, ): ISpan { - val urlDetails = UrlUtils.parse(request.url) + val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) val method = request.method.name val operation = if (operationType != null) "http.graphql.$operationType" else "http.graphql" @@ -231,7 +231,13 @@ constructor( span.finish() } - val breadcrumb = Breadcrumb.http(request.url, request.method.name, statusCode) + val breadcrumb = + Breadcrumb.http( + request.url, + request.method.name, + statusCode, + scopes.options.dataCollectionResolver, + ) request.body?.contentLength.ifHasValidLength { contentLength -> breadcrumb.setData("request_body_size", contentLength) @@ -350,7 +356,7 @@ constructor( // url will be: https://api.github.com/users/getsentry/repos/ // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ // but that's not possible - val urlDetails = UrlUtils.parse(request.url) + val urlDetails = UrlUtils.parse(request.url, scopes.options.dataCollectionResolver) // return if it's not a target match if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt index b4fc25e7be2..cb7df6472dd 100644 --- a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -198,7 +198,12 @@ class SentryApolloInterceptor( val httpRequest = httpResponse.request() val breadcrumb = - Breadcrumb.http(httpRequest.url().toString(), httpRequest.method(), httpResponse.code()) + Breadcrumb.http( + httpRequest.url().toString(), + httpRequest.method(), + httpResponse.code(), + scopes.options.dataCollectionResolver, + ) httpRequest.body()?.contentLength().ifHasValidLength { contentLength -> breadcrumb.setData("request_body_size", contentLength) diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt index 1a569012558..793911a8f49 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt @@ -25,7 +25,7 @@ internal object SentryKtorClientUtils { request: HttpRequest, response: HttpResponse, ) { - val urlDetails = UrlUtils.parse(request.url.toString()) + val urlDetails = UrlUtils.parse(request.url.toString(), scopes.options.dataCollectionResolver) val mechanism = Mechanism().apply { type = "SentryKtorClientPlugin" } val exception = @@ -116,7 +116,12 @@ internal object SentryKtorClientUtils { endTimestamp: SentryDate?, ) { val breadcrumb = - Breadcrumb.http(request.url.toString(), request.method.value, response.status.value) + Breadcrumb.http( + request.url.toString(), + request.method.value, + response.status.value, + scopes.options.dataCollectionResolver, + ) breadcrumb.setData( SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, response.contentLength(), diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt index 7475f09443b..48dd678dd67 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt @@ -34,7 +34,7 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques private var method: String init { - val urlDetails = UrlUtils.parse(request.url.toString()) + val urlDetails = UrlUtils.parse(request.url.toString(), scopes.options.dataCollectionResolver) url = urlDetails.urlOrFallback method = request.method @@ -62,7 +62,7 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques * due to interceptors. */ fun setRequest(request: Request) { - val urlDetails = UrlUtils.parse(request.url.toString()) + val urlDetails = UrlUtils.parse(request.url.toString(), scopes.options.dataCollectionResolver) url = urlDetails.urlOrFallback val host: String = request.url.host @@ -78,8 +78,8 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques breadcrumb.setData("url", urlDetails.url!!) } breadcrumb.setData("method", method.uppercase()) - if (urlDetails.query != null) { - breadcrumb.setData("http.query", urlDetails.query!!) + urlDetails.query?.let { + breadcrumb.setData("http.query", it) } if (urlDetails.fragment != null) { breadcrumb.setData("http.fragment", urlDetails.fragment!!) 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 7031be3b0b3..ed704966610 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -81,7 +81,7 @@ public open class SentryOkHttpInterceptor( override fun intercept(chain: Interceptor.Chain): Response { var request = chain.request() - val urlDetails = UrlUtils.parse(request.url.toString()) + val urlDetails = UrlUtils.parse(request.url.toString(), scopes.options.dataCollectionResolver) val url = urlDetails.urlOrFallback val method = request.method @@ -235,7 +235,13 @@ public open class SentryOkHttpInterceptor( startTimestamp: Long, networkDetailData: NetworkRequestData?, ) { - val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code) + val breadcrumb = + Breadcrumb.http( + request.url.toString(), + request.method, + code, + scopes.options.dataCollectionResolver, + ) // Track request and response body sizes for the breadcrumb request.body?.contentLength().ifHasValidLength { diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt index 07fcac12f06..ce8759e5715 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt @@ -21,7 +21,7 @@ internal object SentryOkHttpUtils { // url will be: https://api.github.com/users/getsentry/repos/ // ideally we'd like a parameterized url: https://api.github.com/users/{user}/repos/ // but that's not possible - val urlDetails = UrlUtils.parse(request.url.toString()) + val urlDetails = UrlUtils.parse(request.url.toString(), scopes.options.dataCollectionResolver) val mechanism = Mechanism().apply { type = "SentryOkHttpInterceptor" } val exception = diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index 9f7d8bc18fb..7b49105dc13 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -504,6 +504,26 @@ class SentryOkHttpInterceptorTest { ) } + @Test + fun `data collection filters failed request query parameters`() { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = { it.dataCollection.setUserInfo(false) }, + ) + + sut.newCall(getRequest(url = "/hello?name=value&token=secret")).execute() + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("name=value&token=[Filtered]", it.request!!.queryString) + }, + any(), + ) + } + @Test fun `captures an error event with request body size`() { val sut = fixture.getSut(captureFailedRequests = true, httpStatusCode = 500) diff --git a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java index acd73bbec7c..520828c0a75 100644 --- a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java +++ b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java @@ -73,7 +73,8 @@ public Response execute(final @NotNull Request request, final @NotNull Request.O final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); ISpan span = activeSpan.startChild("http.client", null, spanOptions); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(request.url(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.httpMethod().name(); span.setDescription(method + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); @@ -158,7 +159,8 @@ private void addBreadcrumb(final @NotNull Request request, final @Nullable Respo Breadcrumb.http( request.url(), request.httpMethod().name(), - response != null ? response.status() : null); + response != null ? response.status() : null, + scopes.getOptions().getDataCollectionResolver()); breadcrumb.setData("request_body_size", request.body() != null ? request.body().length : 0); if (response != null && response.body() != null && response.body().length() != null) { breadcrumb.setData("response_body_size", response.body().length()); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java index 015e56d7949..30d5b648c03 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java @@ -52,7 +52,8 @@ private void addRequestAttributesToScope( if (request.getUrl() == null) { final @Nullable String url = extractUrl(attributes, options); if (url != null) { - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(url); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(url, options.getDataCollectionResolver()); urlDetails.applyToRequest(request); } } @@ -60,7 +61,8 @@ private void addRequestAttributesToScope( if (request.getQueryString() == null) { final @Nullable String query = attributes.get(UrlAttributes.URL_QUERY); if (query != null) { - request.setQueryString(query); + request.setQueryString( + UrlUtils.filterQueryParams(query, options.getDataCollectionResolver())); } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt index 01efc74164f..2d310345050 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -53,6 +53,36 @@ class OpenTelemetryAttributesExtractorTest { thenQueryIsSetTo("q=123456&b=X") } + @Test + fun `data collection filters URL query attributes`() { + fixture.options.dataCollection.setUserInfo(false) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_QUERY to "name=value&token=secret", + ) + ) + + whenExtractingAttributes() + + thenQueryIsSetTo("name=value&token=[Filtered]") + } + + @Test + fun `data collection can disable URL query attributes`() { + fixture.options.dataCollection.queryParams = KeyValueCollectionBehavior.off() + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_QUERY to "name=value", + ) + ) + + whenExtractingAttributes() + + assertNull(fixture.scope.request!!.queryString) + } + @Test fun `when there is an existing request on scope it is filled with more details`() { fixture.scope.request = Request().also { it.bodySize = 123L } diff --git a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java index 777dd13c037..1904d2e5cf0 100644 --- a/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-servlet-jakarta/src/main/java/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessor.java @@ -36,9 +36,11 @@ public SentryRequestHttpServletRequestProcessor( final Request sentryRequest = new Request(); sentryRequest.setMethod(httpRequest.getMethod()); final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(httpRequest.getRequestURL().toString()); + UrlUtils.parse(httpRequest.getRequestURL().toString(), options.getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); - sentryRequest.setQueryString(httpRequest.getQueryString()); + sentryRequest.setQueryString( + UrlUtils.filterQueryParams( + httpRequest.getQueryString(), options.getDataCollectionResolver())); sentryRequest.setHeaders(resolveHeadersMap(httpRequest)); event.setRequest(sentryRequest); diff --git a/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java b/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java index 2034ab3c75d..789ed1b766f 100644 --- a/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-servlet/src/main/java/io/sentry/servlet/SentryRequestHttpServletRequestProcessor.java @@ -36,9 +36,11 @@ public SentryRequestHttpServletRequestProcessor( final Request sentryRequest = new Request(); sentryRequest.setMethod(httpRequest.getMethod()); final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(httpRequest.getRequestURL().toString()); + UrlUtils.parse(httpRequest.getRequestURL().toString(), options.getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); - sentryRequest.setQueryString(httpRequest.getQueryString()); + sentryRequest.setQueryString( + UrlUtils.filterQueryParams( + httpRequest.getQueryString(), options.getDataCollectionResolver())); sentryRequest.setHeaders(resolveHeadersMap(httpRequest)); event.setRequest(sentryRequest); diff --git a/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt index 48be73bdc53..f6bd09894a8 100644 --- a/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt @@ -38,6 +38,33 @@ class SentryRequestHttpServletRequestProcessorTest { assertEquals("param1=xyz", eventRequest.queryString) } + @Test + fun `data collection filters query parameters`() { + val request = + MockMvcRequestBuilders.get(URI.create("http://example.com?name=value&token=secret")) + .buildRequest(MockServletContext()) + val options = SentryOptions().also { it.dataCollection.setUserInfo(false) } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertEquals("name=value&token=[Filtered]", event.request!!.queryString) + } + + @Test + fun `data collection can disable query parameters`() { + val request = + MockMvcRequestBuilders.get(URI.create("http://example.com?name=value")) + .buildRequest(MockServletContext()) + val options = + SentryOptions().also { it.dataCollection.queryParams = KeyValueCollectionBehavior.off() } + val event = SentryEvent() + + SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) + + assertNull(event.request!!.queryString) + } + @Test fun `attaches header with multiple values`() { val request = diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java index abc809933b0..0f1ef3fcfff 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java @@ -37,9 +37,13 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { final Request sentryRequest = new Request(); sentryRequest.setMethod(httpRequest.getMethod()); final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(httpRequest.getRequestURL().toString()); + UrlUtils.parse( + httpRequest.getRequestURL().toString(), + scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); - sentryRequest.setQueryString(httpRequest.getQueryString()); + sentryRequest.setQueryString( + UrlUtils.filterQueryParams( + httpRequest.getQueryString(), scopes.getOptions().getDataCollectionResolver())); final @NotNull List additionalSecurityCookieNames = extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java index 50a8d0539b0..46a31245ba1 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java @@ -63,7 +63,9 @@ public SentrySpanClientHttpRequestInterceptor( final ISpan span = activeSpan.startChild("http.client", null, spanOptions); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.getURI().toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse( + request.getURI().toString(), scopes.getOptions().getDataCollectionResolver()); span.setDescription(methodName + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, methodName.toUpperCase(Locale.ROOT)); urlDetails.applyToSpan(span); @@ -135,7 +137,11 @@ private void addBreadcrumb( final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; final Breadcrumb breadcrumb = - Breadcrumb.http(request.getURI().toString(), methodName, responseStatusCode); + Breadcrumb.http( + request.getURI().toString(), + methodName, + responseStatusCode, + scopes.getOptions().getDataCollectionResolver()); breadcrumb.setData("request_body_size", body.length); final Hint hint = new Hint(); diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java index 6726302a83e..ae2446f121f 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java @@ -15,6 +15,7 @@ import io.sentry.util.Objects; import io.sentry.util.SpanUtils; import io.sentry.util.TracingUtils; +import io.sentry.util.UrlUtils; import java.util.Locale; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -45,9 +46,19 @@ public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); final ISpan span = activeSpan.startChild("http.client", null, spanOptions); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(request.url().toString(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.method().name(); - span.setDescription(method + " " + request.url()); + span.setDescription( + method + + " " + + (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + ? urlDetails.getUrlOrFallback() + : request.url())); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + urlDetails.applyToSpan(span); + } final @NotNull ClientRequest modifiedRequest = maybeAddTracingHeaders(request, span); @@ -113,7 +124,8 @@ private void addBreadcrumb( Breadcrumb.http( request.url().toString(), request.method().name(), - response != null ? response.statusCode().value() : null); + response != null ? response.statusCode().value() : null, + scopes.getOptions().getDataCollectionResolver()); final Hint hint = new Hint(); hint.set(SPRING_EXCHANGE_FILTER_REQUEST, request); diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java index 0b41974a69d..4dd05110bbf 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java @@ -96,7 +96,13 @@ protected void doFirst( hint.set(WEBFLUX_FILTER_RESPONSE, response); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - requestScopes.addBreadcrumb(Breadcrumb.http(request.getURI().toString(), methodName), hint); + final @NotNull Breadcrumb breadcrumb = + Breadcrumb.http( + request.getURI().toString(), + methodName, + null, + requestScopes.getOptions().getDataCollectionResolver()); + requestScopes.addBreadcrumb(breadcrumb, hint); requestScopes.configureScope( scope -> scope.setRequest(sentryRequestResolver.resolveSentryRequest(request))); } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java index 229ab887665..a355d379a83 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java @@ -32,7 +32,8 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { httpRequest.getMethod() != null ? httpRequest.getMethod().name() : "unknown"; sentryRequest.setMethod(methodName); final @NotNull URI uri = httpRequest.getURI(); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(uri.toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(uri.toString(), scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt index bb14538d921..c6c65b560db 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt @@ -270,7 +270,7 @@ class SentryWebFluxTracingFilterTest { verify(fixture.chain).filter(fixture.exchange) verify(fixture.scopes, times(2)).isEnabled - verify(fixture.scopes, times(4)).options + verify(fixture.scopes, times(5)).options verify(fixture.scopes).continueTrace(anyOrNull(), anyOrNull()) verify(fixture.scopes).addBreadcrumb(any(), any()) verify(fixture.scopes).configureScope(any()) diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java index 857027f70d3..81f053f32a1 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java @@ -37,9 +37,13 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { final Request sentryRequest = new Request(); sentryRequest.setMethod(httpRequest.getMethod()); final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(httpRequest.getRequestURL().toString()); + UrlUtils.parse( + httpRequest.getRequestURL().toString(), + scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); - sentryRequest.setQueryString(httpRequest.getQueryString()); + sentryRequest.setQueryString( + UrlUtils.filterQueryParams( + httpRequest.getQueryString(), scopes.getOptions().getDataCollectionResolver())); final @NotNull List additionalSecurityCookieNames = extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java index e305816bb05..0628bc1d30e 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptor.java @@ -63,7 +63,9 @@ public SentrySpanClientHttpRequestInterceptor( final ISpan span = activeSpan.startChild("http.client", null, spanOptions); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.getURI().toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse( + request.getURI().toString(), scopes.getOptions().getDataCollectionResolver()); span.setDescription(methodName + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, methodName.toUpperCase(Locale.ROOT)); urlDetails.applyToSpan(span); @@ -135,7 +137,11 @@ private void addBreadcrumb( final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; final Breadcrumb breadcrumb = - Breadcrumb.http(request.getURI().toString(), methodName, responseStatusCode); + Breadcrumb.http( + request.getURI().toString(), + methodName, + responseStatusCode, + scopes.getOptions().getDataCollectionResolver()); breadcrumb.setData("request_body_size", body.length); final Hint hint = new Hint(); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java index 1189532c0c4..51f68afd3f8 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java @@ -15,6 +15,7 @@ import io.sentry.util.Objects; import io.sentry.util.SpanUtils; import io.sentry.util.TracingUtils; +import io.sentry.util.UrlUtils; import java.util.Locale; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -45,9 +46,19 @@ public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); final ISpan span = activeSpan.startChild("http.client", null, spanOptions); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(request.url().toString(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.method().name(); - span.setDescription(method + " " + request.url()); + span.setDescription( + method + + " " + + (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() + ? urlDetails.getUrlOrFallback() + : request.url())); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + urlDetails.applyToSpan(span); + } final @NotNull ClientRequest modifiedRequest = maybeAddTracingHeaders(request, span); @@ -113,7 +124,8 @@ private void addBreadcrumb( Breadcrumb.http( request.url().toString(), request.method().name(), - response != null ? response.statusCode().value() : null); + response != null ? response.statusCode().value() : null, + scopes.getOptions().getDataCollectionResolver()); final Hint hint = new Hint(); hint.set(SPRING_EXCHANGE_FILTER_REQUEST, request); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java index 57b7b86e40f..84af5a708e0 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java @@ -96,7 +96,13 @@ protected void doFirst( hint.set(WEBFLUX_FILTER_RESPONSE, response); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - requestScopes.addBreadcrumb(Breadcrumb.http(request.getURI().toString(), methodName), hint); + final @NotNull Breadcrumb breadcrumb = + Breadcrumb.http( + request.getURI().toString(), + methodName, + null, + requestScopes.getOptions().getDataCollectionResolver()); + requestScopes.addBreadcrumb(breadcrumb, hint); requestScopes.configureScope( scope -> scope.setRequest(sentryRequestResolver.resolveSentryRequest(request))); } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java index a78a329729b..8a5cf168aa5 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java @@ -32,7 +32,8 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { httpRequest.getMethod() != null ? httpRequest.getMethod().name() : "unknown"; sentryRequest.setMethod(methodName); final @NotNull URI uri = httpRequest.getURI(); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(uri.toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(uri.toString(), scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt index f0b8d62e025..0a01b4cbcc4 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt @@ -270,7 +270,7 @@ class SentryWebFluxTracingFilterTest { verify(fixture.chain).filter(fixture.exchange) verify(fixture.scopes, times(2)).isEnabled - verify(fixture.scopes, times(4)).options + verify(fixture.scopes, times(5)).options verify(fixture.scopes).continueTrace(anyOrNull(), anyOrNull()) verify(fixture.scopes).addBreadcrumb(any(), any()) verify(fixture.scopes).configureScope(any()) diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java index 6e71d22b902..b33f51e41a2 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java @@ -37,9 +37,13 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { final Request sentryRequest = new Request(); sentryRequest.setMethod(httpRequest.getMethod()); final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(httpRequest.getRequestURL().toString()); + UrlUtils.parse( + httpRequest.getRequestURL().toString(), + scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); - sentryRequest.setQueryString(httpRequest.getQueryString()); + sentryRequest.setQueryString( + UrlUtils.filterQueryParams( + httpRequest.getQueryString(), scopes.getOptions().getDataCollectionResolver())); final @NotNull List additionalSecurityCookieNames = extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); diff --git a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptor.java b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptor.java index ed63c5ea080..3a0bd6fc8cb 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptor.java +++ b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptor.java @@ -55,7 +55,9 @@ public SentrySpanClientHttpRequestInterceptor(final @NotNull IScopes scopes) { final ISpan span = activeSpan.startChild("http.client", null, spanOptions); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.getURI().toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse( + request.getURI().toString(), scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToSpan(span); span.setDescription(methodName + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, methodName.toUpperCase(Locale.ROOT)); @@ -127,7 +129,11 @@ private void addBreadcrumb( final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; final Breadcrumb breadcrumb = - Breadcrumb.http(request.getURI().toString(), methodName, responseStatusCode); + Breadcrumb.http( + request.getURI().toString(), + methodName, + responseStatusCode, + scopes.getOptions().getDataCollectionResolver()); breadcrumb.setData("request_body_size", body.length); final Hint hint = new Hint(); diff --git a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientWebRequestFilter.java index e9d787a3dec..eda50c41af2 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientWebRequestFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentrySpanClientWebRequestFilter.java @@ -45,7 +45,8 @@ public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); final ISpan span = activeSpan.startChild("http.client", null, spanOptions); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url().toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(request.url().toString(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.method().name(); span.setDescription(method + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); @@ -115,7 +116,8 @@ private void addBreadcrumb( Breadcrumb.http( request.url().toString(), request.method().name(), - response != null ? response.statusCode().value() : null); + response != null ? response.statusCode().value() : null, + scopes.getOptions().getDataCollectionResolver()); final Hint hint = new Hint(); hint.set(SPRING_EXCHANGE_FILTER_REQUEST, request); diff --git a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java index 5e0c1a9b724..7c6ecfb5ad0 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java @@ -32,7 +32,8 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { httpRequest.getMethod() != null ? httpRequest.getMethod().name() : "unknown"; sentryRequest.setMethod(methodName); final @NotNull URI uri = httpRequest.getURI(); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(uri.toString()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(uri.toString(), scopes.getOptions().getDataCollectionResolver()); urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); diff --git a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java index 03333d95417..30d1152b88a 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java @@ -102,8 +102,13 @@ isTracingEnabled && shouldTraceRequest(requestScopes, request) hint.set(WEBFLUX_FILTER_RESPONSE, response); final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; - requestScopes.addBreadcrumb( - Breadcrumb.http(request.getURI().toString(), methodName), hint); + final @NotNull Breadcrumb breadcrumb = + Breadcrumb.http( + request.getURI().toString(), + methodName, + null, + requestScopes.getOptions().getDataCollectionResolver()); + requestScopes.addBreadcrumb(breadcrumb, hint); requestScopes.configureScope( scope -> scope.setRequest(sentryRequestResolver.resolveSentryRequest(request))); }); diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt index 5d91ec58486..326b5979991 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt @@ -271,7 +271,7 @@ class SentryWebFluxTracingFilterTest { verify(fixture.chain).filter(fixture.exchange) verify(fixture.scopes).isEnabled - verify(fixture.scopes, times(4)).options + verify(fixture.scopes, times(5)).options verify(fixture.scopes).continueTrace(anyOrNull(), anyOrNull()) verify(fixture.scopes).addBreadcrumb(any(), any()) verify(fixture.scopes).configureScope(any()) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 7f686446dd0..d9aa46e379c 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -132,6 +132,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public fun hashCode ()I public static fun http (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun http (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)Lio/sentry/Breadcrumb; + public static fun http (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Lio/sentry/DataCollectionResolver;)Lio/sentry/Breadcrumb; public static fun info (Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun navigation (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun query (Ljava/lang/String;)Lio/sentry/Breadcrumb; @@ -7809,6 +7810,7 @@ public final class io/sentry/util/HttpUtils { public static fun filterOutSecurityCookies (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String; public static fun filterOutSecurityCookiesFromHeader (Ljava/util/Enumeration;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; public static fun filterOutSecurityCookiesFromHeader (Ljava/util/List;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; + public static fun filterQueryParams (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;)Ljava/lang/String; public static fun isHttpClientError (I)Z public static fun isHttpServerError (I)Z public static fun isSecurityCookie (Ljava/lang/String;Ljava/util/List;)Z @@ -8067,7 +8069,9 @@ public final class io/sentry/util/UUIDStringUtils { public final class io/sentry/util/UrlUtils { public static final field SENSITIVE_DATA_SUBSTITUTE Ljava/lang/String; public fun ()V + public static fun filterQueryParams (Ljava/lang/String;Lio/sentry/DataCollectionResolver;)Ljava/lang/String; public static fun parse (Ljava/lang/String;)Lio/sentry/util/UrlUtils$UrlDetails; + public static fun parse (Ljava/lang/String;Lio/sentry/DataCollectionResolver;)Lio/sentry/util/UrlUtils$UrlDetails; public static fun parseNullable (Ljava/lang/String;)Lio/sentry/util/UrlUtils$UrlDetails; } diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index fff6954ee56..b04bddb159a 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -192,8 +192,15 @@ public static Breadcrumb fromMap( * @return the breadcrumb */ public static @NotNull Breadcrumb http(final @NotNull String url, final @NotNull String method) { + return createHttpBreadcrumb(url, method, null); + } + + private static @NotNull Breadcrumb createHttpBreadcrumb( + final @NotNull String url, + final @NotNull String method, + final @Nullable DataCollectionResolver resolver) { final Breadcrumb breadcrumb = new Breadcrumb(); - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(url); + final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(url, resolver); breadcrumb.setType("http"); breadcrumb.setCategory("http"); if (urlDetails.getUrl() != null) { @@ -220,7 +227,21 @@ public static Breadcrumb fromMap( */ public static @NotNull Breadcrumb http( final @NotNull String url, final @NotNull String method, final @Nullable Integer code) { - final Breadcrumb breadcrumb = http(url, method); + final Breadcrumb breadcrumb = createHttpBreadcrumb(url, method, null); + if (code != null) { + breadcrumb.setData("status_code", code); + breadcrumb.setLevel(levelFromHttpStatusCode(code)); + } + return breadcrumb; + } + + @ApiStatus.Internal + public static @NotNull Breadcrumb http( + final @NotNull String url, + final @NotNull String method, + final @Nullable Integer code, + final @Nullable DataCollectionResolver resolver) { + final Breadcrumb breadcrumb = createHttpBreadcrumb(url, method, resolver); if (code != null) { breadcrumb.setData("status_code", code); breadcrumb.setLevel(levelFromHttpStatusCode(code)); diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index d6b9072284e..936571f4ba7 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -4,6 +4,7 @@ import io.sentry.HttpStatusCodeRange; import io.sentry.KeyValueCollectionBehavior; +import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -76,6 +77,40 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return SENSITIVE_HEADERS.contains(header.toUpperCase(Locale.ROOT)); } + public static @Nullable String filterQueryParams( + final @Nullable String query, final @NotNull KeyValueCollectionBehavior behavior) { + if (query == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + final @NotNull StringBuilder filteredQuery = new StringBuilder(); + final @NotNull String[] params = query.split("&", -1); + for (int i = 0; i < params.length; i++) { + if (i > 0) { + filteredQuery.append('&'); + } + + final @NotNull String param = params[i]; + final int separator = param.indexOf('='); + final @NotNull String name = separator < 0 ? param : param.substring(0, separator); + final @NotNull String decodedName = decodeQueryParamName(name); + final boolean sensitive = containsTerm(decodedName, SENSITIVE_DATA_KEYS); + final boolean matchesTerm = containsTerm(decodedName, behavior.getTerms()); + final boolean shouldFilter = + sensitive + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST && matchesTerm) + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST && !matchesTerm); + + filteredQuery.append(name); + if (shouldFilter) { + filteredQuery.append('=').append(SENSITIVE_DATA_SUBSTITUTE); + } else if (separator >= 0) { + filteredQuery.append(param.substring(separator)); + } + } + return filteredQuery.toString(); + } + public static @NotNull Map filterHeaders( final @NotNull Map headers, final @NotNull KeyValueCollectionBehavior behavior) { @@ -104,6 +139,14 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return filteredHeaders; } + private static @NotNull String decodeQueryParamName(final @NotNull String name) { + try { + return URLDecoder.decode(name, "UTF-8"); + } catch (Throwable ignored) { + return name; + } + } + private static boolean containsTerm( final @NotNull String key, final @NotNull List terms) { final @NotNull String normalizedKey = key.toLowerCase(Locale.ROOT); diff --git a/sentry/src/main/java/io/sentry/util/UrlUtils.java b/sentry/src/main/java/io/sentry/util/UrlUtils.java index 6c70cea0495..6dc33795b1d 100644 --- a/sentry/src/main/java/io/sentry/util/UrlUtils.java +++ b/sentry/src/main/java/io/sentry/util/UrlUtils.java @@ -1,5 +1,6 @@ package io.sentry.util; +import io.sentry.DataCollectionResolver; import io.sentry.ISpan; import io.sentry.SpanDataConvention; import io.sentry.protocol.Request; @@ -18,6 +19,11 @@ public final class UrlUtils { } public static @NotNull UrlDetails parse(final @NotNull String url) { + return parse(url, null); + } + + public static @NotNull UrlDetails parse( + final @NotNull String url, final @Nullable DataCollectionResolver resolver) { try { URI uri = new URI(url); if (uri.isAbsolute() && !isValidAbsoluteUrl(uri)) { @@ -28,7 +34,9 @@ public final class UrlUtils { uri.getScheme() == null ? "" : (uri.getScheme() + "://"); final @NotNull String authority = uri.getRawAuthority() == null ? "" : uri.getRawAuthority(); final @NotNull String path = uri.getRawPath() == null ? "" : uri.getRawPath(); - final @Nullable String query = uri.getRawQuery(); + final @Nullable String rawQuery = uri.getRawQuery(); + final @Nullable String query = + resolver == null ? rawQuery : filterQueryParams(rawQuery, resolver); final @Nullable String fragment = uri.getRawFragment(); final @NotNull String filteredUrl = schemeAndSeparator + filterUserInfo(authority) + path; @@ -39,6 +47,13 @@ public final class UrlUtils { } } + public static @Nullable String filterQueryParams( + final @Nullable String query, final @NotNull DataCollectionResolver resolver) { + return resolver.isDataCollectionConfigured() + ? HttpUtils.filterQueryParams(query, resolver.getQueryParams()) + : query; + } + private static boolean isValidAbsoluteUrl(final @NotNull URI uri) { try { uri.toURL(); diff --git a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt index 1e9ed10f806..1da3b82b516 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -10,6 +10,55 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull class HttpUtilsTest { + @Test + fun `query parameter filter disables collection in off mode`() { + assertThat(HttpUtils.filterQueryParams("name=value", KeyValueCollectionBehavior.off())).isNull() + } + + @Test + fun `query parameter deny list filters built-in sensitive and configured terms`() { + assertThat( + HttpUtils.filterQueryParams( + "name=value&access_token=secret&customerId=123", + KeyValueCollectionBehavior.denyList("customer"), + ) + ) + .isEqualTo("name=value&access_token=[Filtered]&customerId=[Filtered]") + } + + @Test + fun `query parameter allow list only retains allowed non-sensitive values`() { + assertThat( + HttpUtils.filterQueryParams( + "name=value&access_token=secret&customerId=123", + KeyValueCollectionBehavior.allowList("name", "access_token"), + ) + ) + .isEqualTo("name=value&access_token=[Filtered]&customerId=[Filtered]") + } + + @Test + fun `query parameter filter matches decoded names and preserves encoding`() { + assertThat( + HttpUtils.filterQueryParams( + "access%5Ftoken=secret&display%20name=Jane+Doe", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("access%5Ftoken=[Filtered]&display%20name=Jane+Doe") + } + + @Test + fun `query parameter filter preserves empty parameters and values`() { + assertThat( + HttpUtils.filterQueryParams( + "name=&flag&&token", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("name=&flag&&token=[Filtered]") + } + @Test fun `header filter disables collection in off mode`() { val filtered = diff --git a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt index a971fbf7d71..91065f6e50e 100644 --- a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt @@ -1,10 +1,76 @@ package io.sentry.util +import com.google.common.truth.Truth.assertThat +import io.sentry.Breadcrumb +import io.sentry.ISpan +import io.sentry.KeyValueCollectionBehavior +import io.sentry.SentryOptions +import io.sentry.SpanDataConvention +import io.sentry.protocol.Request import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify class UrlUtilsTest { + @Test + fun `resolver aware helpers preserve legacy query values`() { + val resolver = SentryOptions().dataCollectionResolver + val details = UrlUtils.parse("https://example.com?token=secret", resolver) + val request = Request() + + details.applyToRequest(request) + + assertThat(request.queryString).isEqualTo("token=secret") + } + + @Test + fun `resolver aware helpers filter request span and breadcrumb queries`() { + val options = SentryOptions().also { it.dataCollection.setUserInfo(false) } + val details = + UrlUtils.parse( + "https://example.com?name=value&token=secret", + options.dataCollectionResolver, + ) + val request = Request() + val span = mock() + val breadcrumb = + Breadcrumb.http( + "https://example.com?name=value&token=secret", + "GET", + null, + options.dataCollectionResolver, + ) + + details.applyToRequest(request) + details.applyToSpan(span) + + assertThat(request.queryString).isEqualTo("name=value&token=[Filtered]") + verify(span).setData(SpanDataConvention.HTTP_QUERY_KEY, "name=value&token=[Filtered]") + assertThat(breadcrumb.getData("http.query")).isEqualTo("name=value&token=[Filtered]") + } + + @Test + fun `resolver aware helpers remove query values in off mode`() { + val options = + SentryOptions().also { it.dataCollection.queryParams = KeyValueCollectionBehavior.off() } + val details = UrlUtils.parse("https://example.com?name=value", options.dataCollectionResolver) + val request = Request() + val breadcrumb = + Breadcrumb.http( + "https://example.com?name=value", + "GET", + null, + options.dataCollectionResolver, + ) + + details.applyToRequest(request) + + assertThat(request.queryString).isNull() + assertThat(breadcrumb.getData("http.query")).isNull() + } + @Test fun `returns null for null`() { assertNull(UrlUtils.parseNullable(null)) From 268920f195b1dca895ad39eb139d3e3bf035f114 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 22 Jul 2026 08:57:58 +0200 Subject: [PATCH 18/63] feat(http): Apply cookie collection policy Filter automatically captured request and response cookies according to the Data Collection policy across Spring and HTTP client integrations. Preserve existing sendDefaultPii behavior when Data Collection is absent. Co-Authored-By: Claude --- .../apollo3/SentryApollo3HttpInterceptor.kt | 34 +++++-- .../SentryApollo3InterceptorClientErrors.kt | 53 ++++++++-- .../apollo4/SentryApollo4HttpInterceptor.kt | 34 +++++-- ...pollo4BuilderExtensionsClientErrorsTest.kt | 53 ++++++++-- .../ktorClient/SentryKtorClientUtils.kt | 28 +++++- .../ktorClient/SentryKtorClientPluginTest.kt | 55 +++++++++++ .../io/sentry/okhttp/SentryOkHttpUtils.kt | 28 +++++- .../io/sentry/okhttp/SentryOkHttpUtilsTest.kt | 58 ++++++++++- .../sentry/spring7/SentryRequestResolver.java | 19 ++-- .../webflux/SentryRequestResolver.java | 11 ++- .../spring/jakarta/SentryRequestResolver.java | 19 ++-- .../webflux/SentryRequestResolver.java | 11 ++- .../sentry/spring/SentryRequestResolver.java | 19 ++-- .../spring/webflux/SentryRequestResolver.java | 11 ++- .../sentry/spring/SentrySpringFilterTest.kt | 49 ++++++++++ sentry/api/sentry.api | 4 + .../main/java/io/sentry/util/HttpUtils.java | 89 +++++++++++++++++ .../test/java/io/sentry/util/HttpUtilsTest.kt | 96 +++++++++++++++++++ 18 files changed, 606 insertions(+), 65 deletions(-) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 0f322481e69..cd4724155fe 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -270,6 +270,28 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value + private fun getRequestCookies(headers: List): String? { + val cookies = getHeader("Cookie", headers) + return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterCookies(cookies, scopes.options.dataCollectionResolver.cookies, null) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + } + + private fun getResponseCookies(headers: List): String? { + val cookies = getHeader("Set-Cookie", headers) + return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + } + private fun getRequestHeaders(headers: List): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = mutableMapOf() @@ -391,9 +413,7 @@ constructor( val sentryRequest = Request().apply { urlDetails.applyToRequest(this) - // Cookie is only sent if isSendDefaultPii is enabled - cookies = - if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null + cookies = getRequestCookies(request.headers) method = request.method.name headers = getRequestHeaders(request.headers) apiTarget = "graphql" @@ -419,13 +439,7 @@ constructor( val sentryResponse = Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = - if (scopes.options.isSendDefaultPii) { - getHeader("Set-Cookie", response.headers) - } else { - null - } + cookies = getResponseCookies(response.headers) headers = getResponseHeaders(response.headers) statusCode = response.statusCode diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt index d2294f45bb0..315f15a97b3 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt @@ -73,6 +73,7 @@ class SentryApollo3InterceptorClientErrors { httpStatusCode: Int = 200, responseBody: String = responseBodyOk, sendDefaultPii: Boolean = false, + includeCookies: Boolean = sendDefaultPii, socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, configureOptions: SentryOptions.() -> Unit = {}, ): ApolloClient { @@ -98,8 +99,8 @@ class SentryApollo3InterceptorClientErrors { .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) - if (sendDefaultPii) { - response.addHeader("Set-Cookie", "Test") + if (includeCookies) { + response.addHeader("Set-Cookie", "theme=dark; Path=/") } server.enqueue(response) @@ -112,8 +113,8 @@ class SentryApollo3InterceptorClientErrors { captureFailedRequests = captureFailedRequests, failedRequestTargets = failedRequestTargets, ) - if (sendDefaultPii) { - builder.addHttpHeader("Cookie", "Test") + if (includeCookies) { + builder.addHttpHeader("Cookie", "theme=dark; sessionId=secret") } return builder.build() @@ -362,6 +363,46 @@ class SentryApollo3InterceptorClientErrors { ) } + @Test + fun `data collection filters cookies`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk, includeCookies = true) { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=[Filtered]; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`() { + val sut = + fixture.getSut( + responseBody = fixture.responseBodyNotOk, + sendDefaultPii = true, + includeCookies = true, + ) { + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + @Test fun `data collection can disable request headers`() { val sut = @@ -387,7 +428,7 @@ class SentryApollo3InterceptorClientErrors { check { val request = it.request!! - assertEquals("Test", request.cookies) + assertEquals("theme=dark; sessionId=secret", request.cookies) assertNotNull(request.headers) assertEquals("LaunchDetails", request.headers?.get("X-APOLLO-OPERATION-NAME")) }, @@ -477,7 +518,7 @@ class SentryApollo3InterceptorClientErrors { check { val response = it.contexts.response!! - assertEquals("Test", response.cookies) + assertEquals("theme=dark; Path=/", response.cookies) assertNotNull(response.headers) assertEquals(200, response.headers?.get("Content-Length")?.toInt()) }, diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index 0a16c669914..cab278f5b37 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -269,6 +269,28 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value + private fun getRequestCookies(headers: List): String? { + val cookies = getHeader("Cookie", headers) + return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterCookies(cookies, scopes.options.dataCollectionResolver.cookies, null) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + } + + private fun getResponseCookies(headers: List): String? { + val cookies = getHeader("Set-Cookie", headers) + return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + } + private fun getRequestHeaders(headers: List): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = mutableMapOf() @@ -390,9 +412,7 @@ constructor( val sentryRequest = Request().apply { urlDetails.applyToRequest(this) - // Cookie is only sent if isSendDefaultPii is enabled - cookies = - if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null + cookies = getRequestCookies(request.headers) method = request.method.name headers = getRequestHeaders(request.headers) apiTarget = "graphql" @@ -418,13 +438,7 @@ constructor( val sentryResponse = Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = - if (scopes.options.isSendDefaultPii) { - getHeader("Set-Cookie", response.headers) - } else { - null - } + cookies = getResponseCookies(response.headers) headers = getResponseHeaders(response.headers) statusCode = response.statusCode diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt index 21bb0dc1b72..9cd2468d161 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -87,6 +87,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( httpStatusCode: Int = 200, responseBody: String = responseBodyOk, sendDefaultPii: Boolean = false, + includeCookies: Boolean = sendDefaultPii, socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, configureOptions: SentryOptions.() -> Unit = {}, ): ApolloClient { @@ -112,8 +113,8 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) - if (sendDefaultPii) { - response.addHeader("Set-Cookie", "Test") + if (includeCookies) { + response.addHeader("Set-Cookie", "theme=dark; Path=/") } server.enqueue(response) @@ -126,8 +127,8 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( captureFailedRequests = captureFailedRequests, failedRequestTargets = failedRequestTargets, ) - if (sendDefaultPii) { - builder.addHttpHeader("Cookie", "Test") + if (includeCookies) { + builder.addHttpHeader("Cookie", "theme=dark; sessionId=secret") } return builder.build() @@ -356,6 +357,46 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection filters cookies`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk, includeCookies = true) { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=[Filtered]; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`() { + val sut = + fixture.getSut( + responseBody = fixture.responseBodyNotOk, + sendDefaultPii = true, + includeCookies = true, + ) { + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + @Test fun `data collection can disable request headers`() { val sut = @@ -381,7 +422,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( check { val request = it.request!! - assertEquals("Test", request.cookies) + assertEquals("theme=dark; sessionId=secret", request.cookies) assertNotNull(request.headers) }, any(), @@ -453,7 +494,7 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( check { val response = it.contexts.response!! - assertEquals("Test", response.cookies) + assertEquals("theme=dark; Path=/", response.cookies) assertNotNull(response.headers) assertEquals(200, response.headers?.get("Content-Length")?.toInt()) }, diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt index 793911a8f49..40af65b8494 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt @@ -36,9 +36,8 @@ internal object SentryKtorClientUtils { val sentryRequest = io.sentry.protocol.Request().apply { - // Cookie is only sent if isSendDefaultPii is enabled urlDetails.applyToRequest(this) - cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null + cookies = getRequestCookies(scopes, request.headers["Cookie"]) method = request.method.value headers = getRequestHeaders(scopes, request.headers) bodySize = request.content.contentLength @@ -46,8 +45,7 @@ internal object SentryKtorClientUtils { val sentryResponse = io.sentry.protocol.Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = if (scopes.options.isSendDefaultPii) response.headers["Set-Cookie"] else null + cookies = getResponseCookies(scopes, response.headers["Set-Cookie"]) headers = getResponseHeaders(scopes, response.headers) statusCode = response.status.value try { @@ -67,6 +65,28 @@ internal object SentryKtorClientUtils { scopes.captureEvent(event, hint) } + private fun getRequestCookies(scopes: IScopes, cookies: String?): String? = + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterCookies( + cookies, + scopes.options.dataCollectionResolver.cookies, + null, + ) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + + private fun getResponseCookies(scopes: IScopes, cookies: String?): String? = + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + private fun getRequestHeaders(scopes: IScopes, headers: Headers): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = diff --git a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt index eab2562fba4..8456f5658ee 100644 --- a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt +++ b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt @@ -104,6 +104,7 @@ class SentryKtorClientPluginTest { MockResponse() .setBody(responseBody) .addHeader("myResponseHeader", "myValue") + .addHeader("Set-Cookie", "theme=dark; Path=/") .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) ) @@ -256,6 +257,60 @@ class SentryKtorClientPluginTest { verify(fixture.scopes, never()).captureEvent(any(), any()) } + @Test + fun `data collection filters cookies`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + }, + ) + + sut.get(fixture.server.url("/hello").toString()) { + headers["Cookie"] = "language=en; theme=dark; sessionId=secret" + } + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals( + "language=en; theme=[Filtered]; sessionId=[Filtered]", + it.request!!.cookies, + ) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + sendDefaultPii = true, + optionsConfiguration = + Sentry.OptionsConfiguration { + it.dataCollection.cookies = KeyValueCollectionBehavior.off() + }, + ) + + sut.get(fixture.server.url("/hello").toString()) { headers["Cookie"] = "theme=dark" } + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + @Test fun `data collection filters request headers`(): Unit = runBlocking { val sut = diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt index ce8759e5715..9299e236604 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt @@ -37,8 +37,7 @@ internal object SentryOkHttpUtils { val sentryRequest = io.sentry.protocol.Request().apply { urlDetails.applyToRequest(this) - // Cookie is only sent if isSendDefaultPii is enabled - cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null + cookies = getRequestCookies(scopes, request.headers["Cookie"]) method = request.method headers = getRequestHeaders(scopes, request.headers) @@ -47,8 +46,7 @@ internal object SentryOkHttpUtils { val sentryResponse = io.sentry.protocol.Response().apply { - // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = if (scopes.options.isSendDefaultPii) response.headers["Set-Cookie"] else null + cookies = getResponseCookies(scopes, response.headers["Set-Cookie"]) headers = getResponseHeaders(scopes, response.headers) statusCode = response.code @@ -67,6 +65,28 @@ internal object SentryOkHttpUtils { } } + private fun getRequestCookies(scopes: IScopes, cookies: String?): String? = + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterCookies( + cookies, + scopes.options.dataCollectionResolver.cookies, + null, + ) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + + private fun getResponseCookies(scopes: IScopes, cookies: String?): String? = + if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { + HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + } else if (scopes.options.isSendDefaultPii) { + cookies + } else { + null + } + private fun getRequestHeaders( scopes: IScopes, requestHeaders: Headers, diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt index d29e8df8260..8ca2aa6f3cb 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt @@ -69,7 +69,7 @@ class SentryOkHttpUtilsTest { private fun getRequest(url: String = "/hello"): Request = Request.Builder() .addHeader("myHeader", "myValue") - .addHeader("Cookie", "cookie") + .addHeader("Cookie", "theme=dark; sessionId=secret") .get() .url(fixture.server.url(url)) .build() @@ -124,6 +124,62 @@ class SentryOkHttpUtilsTest { ) } + @Test + fun `data collection filters request cookies`() { + val sut = fixture.getSut { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("theme") + } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=[Filtered]; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("setCookie", it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection can disable cookies`() { + val sut = fixture.getSut { dataCollection.cookies = KeyValueCollectionBehavior.off() } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertNull(it.request!!.cookies) + assertNull(it.contexts.response!!.cookies) + }, + any(), + ) + } + + @Test + fun `data collection cookie defaults ignore sendDefaultPii`() { + val sut = fixture.getSut(sendDefaultPii = false) { dataCollection.setUserInfo(false) } + val request = getRequest() + val response = sut.newCall(request).execute() + + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("theme=dark; sessionId=[Filtered]", it.request!!.cookies) + assertEquals("setCookie", it.contexts.response!!.cookies) + }, + any(), + ) + } + @Test fun `data collection filters request headers`() { val sut = fixture.getSut { diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java index 0f1ef3fcfff..c66362fe4ed 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java @@ -48,12 +48,19 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - if (scopes.getOptions().isSendDefaultPii()) { - String cookieName = HttpUtils.COOKIE_HEADER_NAME; - final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( - httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames); - sentryRequest.setCookies(toString(filteredHeaders)); + final @NotNull String cookieName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders(cookieName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + additionalSecurityCookieNames))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java index a355d379a83..0d785680748 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java @@ -37,8 +37,15 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - if (scopes.getOptions().isSendDefaultPii()) { - String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders().get(headerName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + Collections.emptyList()))); + } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( HttpUtils.filterOutSecurityCookiesFromHeader( diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java index 81f053f32a1..94316e3ed43 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java @@ -48,12 +48,19 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - if (scopes.getOptions().isSendDefaultPii()) { - String cookieName = HttpUtils.COOKIE_HEADER_NAME; - final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( - httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames); - sentryRequest.setCookies(toString(filteredHeaders)); + final @NotNull String cookieName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders(cookieName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + additionalSecurityCookieNames))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java index 8a5cf168aa5..6383e02fbdc 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java @@ -37,8 +37,15 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - if (scopes.getOptions().isSendDefaultPii()) { - String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders().get(headerName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + Collections.emptyList()))); + } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( HttpUtils.filterOutSecurityCookiesFromHeader( diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java index b33f51e41a2..dcbc697c160 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java @@ -48,12 +48,19 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - if (scopes.getOptions().isSendDefaultPii()) { - String cookieName = HttpUtils.COOKIE_HEADER_NAME; - final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( - httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames); - sentryRequest.setCookies(toString(filteredHeaders)); + final @NotNull String cookieName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders(cookieName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + additionalSecurityCookieNames))); + } else if (scopes.getOptions().isSendDefaultPii()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; } diff --git a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java index 7c6ecfb5ad0..27c1a754be5 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java @@ -37,8 +37,15 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - if (scopes.getOptions().isSendDefaultPii()) { - String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = HttpUtils.COOKIE_HEADER_NAME; + if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { + sentryRequest.setCookies( + toString( + HttpUtils.filterCookiesFromHeader( + httpRequest.getHeaders().get(headerName), + scopes.getOptions().getDataCollectionResolver().getCookies(), + Collections.emptyList()))); + } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( HttpUtils.filterOutSecurityCookiesFromHeader( diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt index b33ad7731d5..ea92a49ceb2 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt @@ -205,6 +205,55 @@ class SentrySpringFilterTest { assertNotNull(fixture.scope.request) { assertNull(it.cookies) } } + @Test + fun `data collection filters cookies and ignores sendDefaultPii`() { + val sentryOptions = + SentryOptions().apply { + isSendDefaultPii = false + dataCollection.cookies = KeyValueCollectionBehavior.denyList("customer") + } + + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header( + "Cookie", + "theme=dark; sessionId=secret; customerId=123; customSession=456", + ) + .buildRequest(servletContextWithCustomCookieName("customSession")), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertEquals( + "theme=dark; sessionId=[Filtered]; customerId=[Filtered]; customSession=[Filtered]", + fixture.scope.request!!.cookies, + ) + } + + @Test + fun `data collection can disable cookies`() { + val sentryOptions = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com")) + .header("Cookie", "theme=dark") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertNull(fixture.scope.request!!.cookies) + } + @Test fun `data collection filters request headers`() { val sentryOptions = diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d9aa46e379c..30f28e17d66 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7806,11 +7806,15 @@ public final class io/sentry/util/HttpUtils { public static final field COOKIE_HEADER_NAME Ljava/lang/String; public fun ()V public static fun containsSensitiveHeader (Ljava/lang/String;)Z + public static fun filterCookies (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/lang/String; + public static fun filterCookiesFromHeader (Ljava/util/Enumeration;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; + public static fun filterCookiesFromHeader (Ljava/util/List;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; public static fun filterHeaders (Ljava/util/Map;Lio/sentry/KeyValueCollectionBehavior;)Ljava/util/Map; public static fun filterOutSecurityCookies (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String; public static fun filterOutSecurityCookiesFromHeader (Ljava/util/Enumeration;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; public static fun filterOutSecurityCookiesFromHeader (Ljava/util/List;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; public static fun filterQueryParams (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;)Ljava/lang/String; + public static fun filterSetCookie (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;)Ljava/lang/String; public static fun isHttpClientError (I)Z public static fun isHttpServerError (I)Z public static fun isSecurityCookie (Ljava/lang/String;Ljava/util/List;)Z diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index 936571f4ba7..ffa785f0006 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -111,6 +111,95 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return filteredQuery.toString(); } + public static @Nullable List filterCookiesFromHeader( + final @Nullable Enumeration headers, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + return headers == null + ? null + : filterCookiesFromHeader( + Collections.list(headers), behavior, additionalSensitiveCookieNames); + } + + public static @Nullable List filterCookiesFromHeader( + final @Nullable List headers, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + if (headers == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + final @NotNull List filteredHeaders = new ArrayList<>(); + for (final String header : headers) { + filteredHeaders.add(filterCookies(header, behavior, additionalSensitiveCookieNames)); + } + return filteredHeaders; + } + + public static @Nullable String filterCookies( + final @Nullable String cookies, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + if (cookies == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + try { + final @NotNull String[] cookieValues = cookies.split(";", -1); + final @NotNull StringBuilder filteredCookies = new StringBuilder(); + for (int i = 0; i < cookieValues.length; i++) { + if (i > 0) { + filteredCookies.append(';'); + } + filteredCookies.append( + filterCookie(cookieValues[i], behavior, additionalSensitiveCookieNames)); + } + return filteredCookies.toString(); + } catch (Throwable ignored) { + return null; + } + } + + public static @Nullable String filterSetCookie( + final @Nullable String cookie, final @NotNull KeyValueCollectionBehavior behavior) { + if (cookie == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + try { + final int attributesSeparator = cookie.indexOf(';'); + final @NotNull String cookieValue = + attributesSeparator < 0 ? cookie : cookie.substring(0, attributesSeparator); + final @NotNull String attributes = + attributesSeparator < 0 ? "" : cookie.substring(attributesSeparator); + return filterCookie(cookieValue, behavior, null) + attributes; + } catch (Throwable ignored) { + return null; + } + } + + private static @NotNull String filterCookie( + final @NotNull String cookie, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + final int separator = cookie.indexOf('='); + final @NotNull String name = separator < 0 ? cookie : cookie.substring(0, separator); + final @NotNull String normalizedName = name.trim(); + final boolean sensitive = + containsTerm(normalizedName, SENSITIVE_DATA_KEYS) + || isSecurityCookie(normalizedName, additionalSensitiveCookieNames); + final boolean matchesTerm = containsTerm(normalizedName, behavior.getTerms()); + final boolean shouldFilter = + sensitive + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST && matchesTerm) + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST && !matchesTerm); + + if (shouldFilter) { + return name + "=" + SENSITIVE_DATA_SUBSTITUTE; + } + return cookie; + } + public static @NotNull Map filterHeaders( final @NotNull Map headers, final @NotNull KeyValueCollectionBehavior behavior) { diff --git a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt index 1da3b82b516..3eb3401a32d 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -59,6 +59,102 @@ class HttpUtilsTest { .isEqualTo("name=&flag&&token=[Filtered]") } + @Test + fun `cookie filter disables collection in off mode`() { + assertThat( + HttpUtils.filterCookies( + "name=value", + KeyValueCollectionBehavior.off(), + emptyList(), + ) + ) + .isNull() + } + + @Test + fun `cookie deny list filters built-in configured and integration sensitive names`() { + assertThat( + HttpUtils.filterCookies( + "name=value; sessionId=secret; customerId=123; frameworkSession=456", + KeyValueCollectionBehavior.denyList("customer"), + listOf("frameworkSession"), + ) + ) + .isEqualTo( + "name=value; sessionId=[Filtered]; customerId=[Filtered]; frameworkSession=[Filtered]" + ) + } + + @Test + fun `cookie allow list only retains allowed non-sensitive values`() { + assertThat( + HttpUtils.filterCookies( + "theme=dark; sessionId=secret; language=en", + KeyValueCollectionBehavior.allowList("theme", "session"), + emptyList(), + ) + ) + .isEqualTo("theme=dark; sessionId=[Filtered]; language=[Filtered]") + } + + @Test + fun `cookie filter uses only the first equals separator`() { + assertThat( + HttpUtils.filterCookies( + "theme=dark=contrast; token=abc=123", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark=contrast; token=[Filtered]") + } + + @Test + fun `set cookie filter preserves attributes`() { + assertThat( + HttpUtils.filterSetCookie( + "sessionId=secret; Path=/; HttpOnly; SameSite=Lax", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("sessionId=[Filtered]; Path=/; HttpOnly; SameSite=Lax") + } + + @Test + fun `set cookie allow list retains allowed non-sensitive value and attributes`() { + assertThat( + HttpUtils.filterSetCookie( + "theme=dark; Path=/; Secure", + KeyValueCollectionBehavior.allowList("theme"), + ) + ) + .isEqualTo("theme=dark; Path=/; Secure") + } + + @Test + fun `set cookie filter disables collection in off mode`() { + assertThat( + HttpUtils.filterSetCookie( + "theme=dark; Path=/", + KeyValueCollectionBehavior.off(), + ) + ) + .isNull() + } + + @Test + fun `cookie header filter processes every header value`() { + assertThat( + HttpUtils.filterCookiesFromHeader( + listOf("theme=dark; SID=secret", "language=en"), + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .containsExactly("theme=dark; SID=[Filtered]", "language=en") + .inOrder() + } + @Test fun `header filter disables collection in off mode`() { val filtered = From a0d6c1cf7c2d1bd651645bc9ebf29a73b20a1aff Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 22 Jul 2026 09:21:42 +0200 Subject: [PATCH 19/63] feat(user): Apply user information collection policy Gate automatic user and device identity enrichment across core, Android, and Spring integrations with the Data Collection policy. Preserve legacy sendDefaultPii and Android installation identity behavior when Data Collection is absent. Co-Authored-By: Claude --- .../ApplicationExitInfoEventProcessor.java | 7 ++- .../core/DefaultAndroidEventProcessor.java | 8 ++- .../sentry/android/core/DeviceInfoUtil.java | 3 +- .../android/core/InternalSentrySdk.java | 3 +- .../ApplicationExitInfoEventProcessorTest.kt | 55 +++++++++++++++++++ .../core/DefaultAndroidEventProcessorTest.kt | 28 ++++++++++ .../sentry/android/core/DeviceInfoUtilTest.kt | 18 ++++++ .../android/core/InternalSentrySdkTest.kt | 23 ++++++++ .../HttpServletRequestSentryUserProvider.java | 2 +- .../io/sentry/spring7/SentryUserFilter.java | 2 +- .../SpringSecuritySentryUserProvider.java | 2 +- ...ttpServletRequestSentryUserProviderTest.kt | 37 +++++++++++++ .../io/sentry/spring7/SentryUserFilterTest.kt | 37 ++++++++++++- .../SpringSecuritySentryUserProviderTest.kt | 21 ++++++- .../HttpServletRequestSentryUserProvider.java | 2 +- .../spring/jakarta/SentryUserFilter.java | 2 +- .../SpringSecuritySentryUserProvider.java | 2 +- ...ttpServletRequestSentryUserProviderTest.kt | 37 +++++++++++++ .../spring/jakarta/SentryUserFilterTest.kt | 35 +++++++++++- .../SpringSecuritySentryUserProviderTest.kt | 21 ++++++- .../HttpServletRequestSentryUserProvider.java | 2 +- .../io/sentry/spring/SentryUserFilter.java | 2 +- .../SpringSecuritySentryUserProvider.java | 2 +- ...ttpServletRequestSentryUserProviderTest.kt | 37 +++++++++++++ .../io/sentry/spring/SentryUserFilterTest.kt | 35 +++++++++++- .../SpringSecuritySentryUserProviderTest.kt | 21 ++++++- sentry/api/sentry.api | 1 + .../io/sentry/DataCollectionResolver.java | 4 ++ .../java/io/sentry/MainEventProcessor.java | 2 +- .../src/main/java/io/sentry/TraceContext.java | 11 ---- .../io/sentry/DataCollectionResolverTest.kt | 11 ++++ .../java/io/sentry/MainEventProcessorTest.kt | 34 ++++++++++++ 32 files changed, 471 insertions(+), 36 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 2eca0e68b5b..62b32dd76ce 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -568,10 +568,10 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { } // userId should be set even if event is Cached as the userId is static and won't change anyway. - if (user.getId() == null) { + if (user.getId() == null && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { user.setId(getDeviceId()); } - if (user.getIpAddress() == null && options.isSendDefaultPii()) { + if (user.getIpAddress() == null && options.getDataCollectionResolver().isUserInfo()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } @@ -635,7 +635,8 @@ private void setDevice(final @NotNull SentryBaseEvent event) { device.setScreenDpi(displayMetrics.densityDpi); } - if (device.getId() == null) { + if (device.getId() == null + && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { device.setId(getDeviceId()); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 83f892573e4..520706b352c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -175,10 +175,10 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { } // userId should be set even if event is Cached as the userId is static and won't change anyway. - if (user.getId() == null) { + if (user.getId() == null && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { user.setId(Installation.id(context)); } - if (user.getIpAddress() == null && options.isSendDefaultPii()) { + if (user.getIpAddress() == null && options.getDataCollectionResolver().isUserInfo()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } @@ -374,7 +374,9 @@ private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { */ public @NotNull User getDefaultUser(final @NotNull Context context) { final @NotNull User user = new User(); - user.setId(Installation.id(context)); + if (options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { + user.setId(Installation.id(context)); + } return user; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 63b88c0e440..d988cbd090e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -130,7 +130,8 @@ public Device collectDeviceInformation( device.setBootTime(getBootTime()); device.setTimezone(getTimeZone()); - if (device.getId() == null) { + if (device.getId() == null + && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { device.setId(getDeviceId()); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 2779f803a69..822a65727d0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -99,7 +99,8 @@ public static Map serializeScope( user = new User(); scope.setUser(user); } - if (user.getId() == null) { + if (user.getId() == null + && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { try { user.setId(Installation.id(context)); } catch (RuntimeException e) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index e7583429910..7eaa269f39f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -228,6 +228,26 @@ class ApplicationExitInfoEventProcessorTest { assertEquals(SentryBaseEvent.DEFAULT_PLATFORM, processed.platform) } + @Test + fun `when user info is disabled, does not set device id`() { + fixture.options.dataCollection.setUserInfo(false) + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + + val processed = processEvent(hint) + + assertNull(processed.contexts.device!!.id) + } + + @Test + fun `when user info is enabled, sets device id`() { + fixture.options.dataCollection.setUserInfo(true) + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) + + val processed = processEvent(hint, isSendDefaultPii = false) + + assertNotNull(processed.contexts.device!!.id) + } + @Test fun `when backfillable event is not enrichable, sets OS`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) @@ -336,6 +356,28 @@ class ApplicationExitInfoEventProcessorTest { assertNull(processed.user!!.ipAddress) } + @Test + fun `when user info is disabled, does not backfill automatic user data`() { + fixture.options.dataCollection.setUserInfo(false) + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processed = processEvent(hint, isSendDefaultPii = true, populateScopeCache = true) + + assertEquals("bot", processed.user!!.username) + assertEquals("bot@me.com", processed.user!!.id) + assertNull(processed.user!!.ipAddress) + } + + @Test + fun `when user info is enabled, backfills automatic user data`() { + fixture.options.dataCollection.setUserInfo(true) + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processed = processEvent(hint, isSendDefaultPii = false, populateScopeCache = true) + + assertEquals("bot", processed.user!!.username) + assertEquals("bot@me.com", processed.user!!.id) + assertEquals("{{auto}}", processed.user!!.ipAddress) + } + @Test fun `when backfillable event is enrichable, backfills serialized options data`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) @@ -435,6 +477,19 @@ class ApplicationExitInfoEventProcessorTest { assertEquals(Installation.deviceId, processed!!.user!!.id) } + @Test + fun `when user info is disabled, does not set installation id for missing user id`() { + fixture.options.dataCollection.setUserInfo(false) + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val original = SentryEvent() + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(USER_FILENAME, User()) + + val processed = processor.process(original, hint) + + assertNull(processed!!.user!!.id) + } + @Test fun `when event has some fields set, does not override them`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index 091a75e1295..fbcd20b99fb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -285,6 +285,34 @@ class DefaultAndroidEventProcessorTest { assertNotNull(event.user) { assertEquals("{{auto}}", it.ipAddress) } } + @Test + fun `when user info is disabled, does not set automatic user data`() { + fixture.options.dataCollection.setUserInfo(false) + val sut = fixture.getSut(context, isSendDefaultPii = true) + val event = SentryEvent().apply { user = User() } + + sut.process(event, Hint()) + + assertNotNull(event.user) { + assertNull(it.id) + assertNull(it.ipAddress) + } + } + + @Test + fun `when user info is enabled, sets automatic user data`() { + fixture.options.dataCollection.setUserInfo(true) + val sut = fixture.getSut(context, isSendDefaultPii = false) + val event = SentryEvent().apply { user = User() } + + sut.process(event, Hint()) + + assertNotNull(event.user) { + assertNotNull(it.id) + assertEquals("{{auto}}", it.ipAddress) + } + } + @Test fun `when event has ip address set, keeps original ip address`() { val sut = fixture.getSut(context) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index faf993e1610..49c828b551e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -53,6 +53,24 @@ class DeviceInfoUtilTest { assertNotNull(deviceInfo.memorySize) } + @Test + fun `does not set device id when user info is disabled`() { + val options = SentryAndroidOptions().apply { dataCollection.setUserInfo(false) } + val deviceInfo = + DeviceInfoUtil.getInstance(context, options).collectDeviceInformation(false, false) + + assertNull(deviceInfo.id) + } + + @Test + fun `sets device id when user info is enabled`() { + val options = SentryAndroidOptions().apply { dataCollection.setUserInfo(true) } + val deviceInfo = + DeviceInfoUtil.getInstance(context, options).collectDeviceInformation(false, false) + + assertNotNull(deviceInfo.id) + } + @Test fun `sets default timezone`() { val deviceInfoUtil = DeviceInfoUtil.getInstance(context, SentryAndroidOptions()) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index 5917d44d11d..8c552a8b633 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -38,6 +38,7 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -326,6 +327,28 @@ class InternalSentrySdkTest { assertTrue((serializedScope["user"] as Map<*, *>).containsKey("id")) } + @Test + fun `serializeScope does not provide fallback user id when user info is disabled`() { + val options = SentryAndroidOptions().apply { dataCollection.setUserInfo(false) } + val scope = Scope(options) + scope.user = null + + val serializedScope = InternalSentrySdk.serializeScope(context, options, scope) + + assertFalse((serializedScope["user"] as Map<*, *>).containsKey("id")) + } + + @Test + fun `serializeScope provides fallback user id when user info is enabled`() { + val options = SentryAndroidOptions().apply { dataCollection.setUserInfo(true) } + val scope = Scope(options) + scope.user = null + + val serializedScope = InternalSentrySdk.serializeScope(context, options, scope) + + assertTrue((serializedScope["user"] as Map<*, *>).containsKey("id")) + } + @Test fun `serializeScope does not override user-id`() { val options = SentryAndroidOptions() diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java index 54ad7602ae0..44ae584ec17 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java @@ -23,7 +23,7 @@ public HttpServletRequestSentryUserProvider(final @NotNull SentryOptions options @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { final ServletRequestAttributes servletRequestAttributes = diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java index b7e226929a5..da9d2f0f77e 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java @@ -46,7 +46,7 @@ protected void doFilterInternal( for (final SentryUserProvider provider : sentryUserProviders) { apply(user, provider.provideUser()); } - if (scopes.getOptions().isSendDefaultPii()) { + if (scopes.getOptions().getDataCollectionResolver().isUserInfo()) { if (IpAddressUtils.isDefault(user.getIpAddress())) { // unset {{auto}} as it would set the server's ip address as a user ip address user.setIpAddress(null); diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java index 164a43c5bd2..ff3f118898a 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java @@ -22,7 +22,7 @@ public SpringSecuritySentryUserProvider(final @NotNull SentryOptions options) { @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final SecurityContext context = SecurityContextHolder.getContext(); if (context != null && context.getAuthentication() != null) { final User user = new User(); diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt index 5254270a05c..16bddd0c27e 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt @@ -45,6 +45,43 @@ class HttpServletRequestSentryUserProviderTest { assertEquals("janesmith", result.username) } + @Test + fun `when user info is disabled, does not attach user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.setUserInfo(false) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNull(result) + } + + @Test + fun `when user info is enabled, attaches user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = false + dataCollection.setUserInfo(true) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNotNull(result) + assertEquals("janesmith", result.username) + } + @Test fun `when sendDefaultPii is set to false, does not attach user data Sentry Event`() { val principal = mock() diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt index 6284e8241ae..92327456e13 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt @@ -23,9 +23,14 @@ class SentryUserFilterTest { fun getSut( isSendDefaultPii: Boolean = false, + userInfo: Boolean? = null, userProviders: List, ): SentryUserFilter { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } whenever(scopes.options).thenReturn(options) return SentryUserFilter(scopes, userProviders) } @@ -76,7 +81,7 @@ class SentryUserFilterTest { } @Test - fun `merges user#others with existing user#others set on SentryEvent`() { + fun `merges user#data with existing user#data set on SentryEvent`() { val filter = fixture.getSut( userProviders = @@ -118,6 +123,34 @@ class SentryUserFilterTest { verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) } + @Test + fun `when user info is disabled, preserves auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = true, + userInfo = false, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals("{{auto}}", it.ipAddress) }) + } + + @Test + fun `when user info is enabled, removes auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = false, + userInfo = true, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) + } + private fun assertEquals(user1: User, user2: User) { assertEquals(user1.username, user2.username) assertEquals(user1.id, user2.id) diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt index 6330405999c..ca931ce3b8f 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt @@ -16,8 +16,13 @@ class SpringSecuritySentryUserProviderTest { fun getSut( isSendDefaultPii: Boolean = true, username: String? = null, + userInfo: Boolean? = null, ): SpringSecuritySentryUserProvider { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } val securityContext = mock() if (username != null) { val authentication = mock() @@ -47,6 +52,20 @@ class SpringSecuritySentryUserProviderTest { assertNull(user) } + @Test + fun `when user info is disabled, returns null even if sendDefaultPii is true`() { + val provider = fixture.getSut(isSendDefaultPii = true, username = "name", userInfo = false) + + assertNull(provider.provideUser()) + } + + @Test + fun `when user info is enabled, returns user even if sendDefaultPii is false`() { + val provider = fixture.getSut(isSendDefaultPii = false, username = "name", userInfo = true) + + assertNotNull(provider.provideUser()) { assertEquals("name", it.username) } + } + @Test fun `when send default pii is set to true and security context is not set, returns null`() { val provider = fixture.getSut(true) diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java index 6174da0dc5f..b7f4646b4a8 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/HttpServletRequestSentryUserProvider.java @@ -23,7 +23,7 @@ public HttpServletRequestSentryUserProvider(final @NotNull SentryOptions options @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { final ServletRequestAttributes servletRequestAttributes = diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java index 31cc73a3468..23a77f79f0d 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryUserFilter.java @@ -46,7 +46,7 @@ protected void doFilterInternal( for (final SentryUserProvider provider : sentryUserProviders) { apply(user, provider.provideUser()); } - if (scopes.getOptions().isSendDefaultPii()) { + if (scopes.getOptions().getDataCollectionResolver().isUserInfo()) { if (IpAddressUtils.isDefault(user.getIpAddress())) { // unset {{auto}} as it would set the server's ip address as a user ip address user.setIpAddress(null); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringSecuritySentryUserProvider.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringSecuritySentryUserProvider.java index d36bc4bf2b0..c3f55166c30 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringSecuritySentryUserProvider.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SpringSecuritySentryUserProvider.java @@ -22,7 +22,7 @@ public SpringSecuritySentryUserProvider(final @NotNull SentryOptions options) { @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final SecurityContext context = SecurityContextHolder.getContext(); if (context != null && context.getAuthentication() != null) { final User user = new User(); diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/HttpServletRequestSentryUserProviderTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/HttpServletRequestSentryUserProviderTest.kt index f2cce25574d..f3cf07525a2 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/HttpServletRequestSentryUserProviderTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/HttpServletRequestSentryUserProviderTest.kt @@ -45,6 +45,43 @@ class HttpServletRequestSentryUserProviderTest { assertEquals("janesmith", result.username) } + @Test + fun `when user info is disabled, does not attach user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.setUserInfo(false) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNull(result) + } + + @Test + fun `when user info is enabled, attaches user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = false + dataCollection.setUserInfo(true) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNotNull(result) + assertEquals("janesmith", result.username) + } + @Test fun `when sendDefaultPii is set to false, does not attach user data Sentry Event`() { val principal = mock() diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt index c790f3e9997..15a7bf377cd 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryUserFilterTest.kt @@ -23,9 +23,14 @@ class SentryUserFilterTest { fun getSut( isSendDefaultPii: Boolean = false, + userInfo: Boolean? = null, userProviders: List, ): SentryUserFilter { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } whenever(scopes.options).thenReturn(options) return SentryUserFilter(scopes, userProviders) } @@ -118,6 +123,34 @@ class SentryUserFilterTest { verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) } + @Test + fun `when user info is disabled, preserves auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = true, + userInfo = false, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals("{{auto}}", it.ipAddress) }) + } + + @Test + fun `when user info is enabled, removes auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = false, + userInfo = true, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) + } + private fun assertEquals(user1: User, user2: User) { assertEquals(user1.username, user2.username) assertEquals(user1.id, user2.id) diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SpringSecuritySentryUserProviderTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SpringSecuritySentryUserProviderTest.kt index 80f8efc9ce2..8bd503c3180 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SpringSecuritySentryUserProviderTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SpringSecuritySentryUserProviderTest.kt @@ -16,8 +16,13 @@ class SpringSecuritySentryUserProviderTest { fun getSut( isSendDefaultPii: Boolean = true, username: String? = null, + userInfo: Boolean? = null, ): SpringSecuritySentryUserProvider { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } val securityContext = mock() if (username != null) { val authentication = mock() @@ -47,6 +52,20 @@ class SpringSecuritySentryUserProviderTest { assertNull(user) } + @Test + fun `when user info is disabled, returns null even if sendDefaultPii is true`() { + val provider = fixture.getSut(isSendDefaultPii = true, username = "name", userInfo = false) + + assertNull(provider.provideUser()) + } + + @Test + fun `when user info is enabled, returns user even if sendDefaultPii is false`() { + val provider = fixture.getSut(isSendDefaultPii = false, username = "name", userInfo = true) + + assertNotNull(provider.provideUser()) { assertEquals("name", it.username) } + } + @Test fun `when send default pii is set to true and security context is not set, returns null`() { val provider = fixture.getSut(true) diff --git a/sentry-spring/src/main/java/io/sentry/spring/HttpServletRequestSentryUserProvider.java b/sentry-spring/src/main/java/io/sentry/spring/HttpServletRequestSentryUserProvider.java index c24d2c2ff10..9951e6961dd 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/HttpServletRequestSentryUserProvider.java +++ b/sentry-spring/src/main/java/io/sentry/spring/HttpServletRequestSentryUserProvider.java @@ -23,7 +23,7 @@ public HttpServletRequestSentryUserProvider(final @NotNull SentryOptions options @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { final ServletRequestAttributes servletRequestAttributes = diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java b/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java index e0b4e9c1ba8..18e1c0d2875 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryUserFilter.java @@ -46,7 +46,7 @@ protected void doFilterInternal( for (final SentryUserProvider provider : sentryUserProviders) { apply(user, provider.provideUser()); } - if (scopes.getOptions().isSendDefaultPii()) { + if (scopes.getOptions().getDataCollectionResolver().isUserInfo()) { if (IpAddressUtils.isDefault(user.getIpAddress())) { // unset {{auto}} as it would set the server's ip address as a user ip address user.setIpAddress(null); diff --git a/sentry-spring/src/main/java/io/sentry/spring/SpringSecuritySentryUserProvider.java b/sentry-spring/src/main/java/io/sentry/spring/SpringSecuritySentryUserProvider.java index 23b7820ad94..ef361b0b1f6 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SpringSecuritySentryUserProvider.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SpringSecuritySentryUserProvider.java @@ -22,7 +22,7 @@ public SpringSecuritySentryUserProvider(final @NotNull SentryOptions options) { @Override public @Nullable User provideUser() { - if (options.isSendDefaultPii()) { + if (options.getDataCollectionResolver().isUserInfo()) { final SecurityContext context = SecurityContextHolder.getContext(); if (context != null && context.getAuthentication() != null) { final User user = new User(); diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/HttpServletRequestSentryUserProviderTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/HttpServletRequestSentryUserProviderTest.kt index 46027a1c09f..3f3cc08bdcd 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/HttpServletRequestSentryUserProviderTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/HttpServletRequestSentryUserProviderTest.kt @@ -45,6 +45,43 @@ class HttpServletRequestSentryUserProviderTest { assertEquals("janesmith", result.username) } + @Test + fun `when user info is disabled, does not attach user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.setUserInfo(false) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNull(result) + } + + @Test + fun `when user info is enabled, attaches user data`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = + SentryOptions().apply { + isSendDefaultPii = false + dataCollection.setUserInfo(true) + } + val result = HttpServletRequestSentryUserProvider(options).provideUser() + + assertNotNull(result) + assertEquals("janesmith", result.username) + } + @Test fun `when sendDefaultPii is set to false, does not attach user data Sentry Event`() { val principal = mock() diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt index f545e605560..07283bd5b95 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserFilterTest.kt @@ -23,9 +23,14 @@ class SentryUserFilterTest { fun getSut( isSendDefaultPii: Boolean = false, + userInfo: Boolean? = null, userProviders: List, ): SentryUserFilter { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } whenever(scopes.options).thenReturn(options) return SentryUserFilter(scopes, userProviders) } @@ -118,6 +123,34 @@ class SentryUserFilterTest { verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) } + @Test + fun `when user info is disabled, preserves auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = true, + userInfo = false, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals("{{auto}}", it.ipAddress) }) + } + + @Test + fun `when user info is enabled, removes auto ip from a custom provider`() { + val filter = + fixture.getSut( + isSendDefaultPii = false, + userInfo = true, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) + } + private fun assertEquals(user1: User, user2: User) { assertEquals(user1.username, user2.username) assertEquals(user1.id, user2.id) diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SpringSecuritySentryUserProviderTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SpringSecuritySentryUserProviderTest.kt index 3fa443658d3..7ba3ea787e9 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SpringSecuritySentryUserProviderTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SpringSecuritySentryUserProviderTest.kt @@ -16,8 +16,13 @@ class SpringSecuritySentryUserProviderTest { fun getSut( isSendDefaultPii: Boolean = true, username: String? = null, + userInfo: Boolean? = null, ): SpringSecuritySentryUserProvider { - val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val options = + SentryOptions().apply { + this.isSendDefaultPii = isSendDefaultPii + userInfo?.let { dataCollection.setUserInfo(it) } + } val securityContext = mock() if (username != null) { val authentication = mock() @@ -47,6 +52,20 @@ class SpringSecuritySentryUserProviderTest { assertNull(user) } + @Test + fun `when user info is disabled, returns null even if sendDefaultPii is true`() { + val provider = fixture.getSut(isSendDefaultPii = true, username = "name", userInfo = false) + + assertNull(provider.provideUser()) + } + + @Test + fun `when user info is enabled, returns user even if sendDefaultPii is false`() { + val provider = fixture.getSut(isSendDefaultPii = false, username = "name", userInfo = true) + + assertNotNull(provider.provideUser()) { assertEquals("name", it.username) } + } + @Test fun `when send default pii is set to true and security context is not set, returns null`() { val provider = fixture.getSut(true) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 30f28e17d66..55d5a34e3ba 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -436,6 +436,7 @@ public final class io/sentry/DataCollectionResolver { public fun isOutgoingResponseBody ()Z public fun isOutgoingResponseBodyWithLegacyBodyGate ()Z public fun isUserInfo ()Z + public fun isUserInfoWithLegacyAlways ()Z } public final class io/sentry/DateUtils { diff --git a/sentry/src/main/java/io/sentry/DataCollectionResolver.java b/sentry/src/main/java/io/sentry/DataCollectionResolver.java index 16d27b68781..78da42e8e1d 100644 --- a/sentry/src/main/java/io/sentry/DataCollectionResolver.java +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -27,6 +27,10 @@ public boolean isUserInfo() { return explicitOrSendDefaultPii(options.getDataCollection().getUserInfo(), true); } + public boolean isUserInfoWithLegacyAlways() { + return explicitOrDefault(options.getDataCollection().getUserInfo(), true, true); + } + public boolean isDatabaseQueryData() { return explicitOrSendDefaultPii(options.getDataCollection().getDatabaseQueryData(), true); } diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index d84c9e47be8..d72783cfe9c 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -206,7 +206,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { user = new User(); event.setUser(user); } - if (user.getIpAddress() == null && options.isSendDefaultPii()) { + if (user.getIpAddress() == null && options.getDataCollectionResolver().isUserInfo()) { user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); } } diff --git a/sentry/src/main/java/io/sentry/TraceContext.java b/sentry/src/main/java/io/sentry/TraceContext.java index b10954f5285..1bb5508f85b 100644 --- a/sentry/src/main/java/io/sentry/TraceContext.java +++ b/sentry/src/main/java/io/sentry/TraceContext.java @@ -1,7 +1,6 @@ package io.sentry; import io.sentry.protocol.SentryId; -import io.sentry.protocol.User; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.Map; @@ -81,16 +80,6 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { this.sampleRand = sampleRand; } - @SuppressWarnings("UnusedMethod") - private static @Nullable String getUserId( - final @NotNull SentryOptions options, final @Nullable User user) { - if (options.isSendDefaultPii() && user != null) { - return user.getId(); - } - - return null; - } - public @NotNull SentryId getTraceId() { return traceId; } diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt index d84df7ae6a7..89823d6c3ff 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -55,6 +55,17 @@ class DataCollectionResolverTest { assertThat(options.dataCollectionResolver.isUserInfo).isTrue() } + @Test + fun `user info legacy always variant preserves collection when namespace is absent`() { + val options = SentryOptions().apply { isSendDefaultPii = false } + + assertThat(options.dataCollectionResolver.isUserInfoWithLegacyAlways).isTrue() + + options.dataCollection.setUserInfo(false) + + assertThat(options.dataCollectionResolver.isUserInfoWithLegacyAlways).isFalse() + } + @Test fun `omitted booleans use data collection defaults once namespace is explicit`() { val options = SentryOptions().apply { isSendDefaultPii = false } diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index fe5c835c90f..643b850e86e 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.hints.AbnormalExit import io.sentry.hints.ApplyScopeData import io.sentry.protocol.DebugMeta @@ -321,6 +322,39 @@ class MainEventProcessorTest { assertNotNull(event.user) { assertNull(it.ipAddress) } } + @Test + fun `when user info is disabled, do not enrich ip address if sendDefaultPii is true`() { + fixture.sentryOptions.dataCollection.setUserInfo(false) + val sut = fixture.getSut(sendDefaultPii = true) + val event = SentryEvent() + + sut.process(event, Hint()) + + assertThat(event.user?.ipAddress).isNull() + } + + @Test + fun `when user info is enabled, enrich ip address if sendDefaultPii is false`() { + fixture.sentryOptions.dataCollection.setUserInfo(true) + val sut = fixture.getSut(sendDefaultPii = false) + val event = SentryEvent() + + sut.process(event, Hint()) + + assertThat(event.user?.ipAddress).isEqualTo("{{auto}}") + } + + @Test + fun `when another data collection setting is configured, omitted user info uses its default`() { + fixture.sentryOptions.dataCollection.cookies = KeyValueCollectionBehavior.off() + val sut = fixture.getSut(sendDefaultPii = false) + val event = SentryEvent() + + sut.process(event, Hint()) + + assertThat(event.user?.ipAddress).isEqualTo("{{auto}}") + } + @Test fun `when event has ip address set, keeps original ip address`() { val sut = fixture.getSut(sendDefaultPii = true) From 1987b1e24b7be268104ffeb3897aaaae5735c77e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 23 Jul 2026 10:30:09 +0200 Subject: [PATCH 20/63] fix(core): Filter malformed cookie pairs Replace malformed request cookie pairs and invalid Set-Cookie values with the filtered placeholder. Preserve valid empty values, padded values, and response cookie attributes. Co-Authored-By: Claude --- .../main/java/io/sentry/util/HttpUtils.java | 18 +++- .../test/java/io/sentry/util/HttpUtilsTest.kt | 85 +++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index ffa785f0006..2b3f60c38dc 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -156,7 +156,7 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { } return filteredCookies.toString(); } catch (Throwable ignored) { - return null; + return SENSITIVE_DATA_SUBSTITUTE; } } @@ -170,11 +170,14 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { final int attributesSeparator = cookie.indexOf(';'); final @NotNull String cookieValue = attributesSeparator < 0 ? cookie : cookie.substring(0, attributesSeparator); + if (!isValidCookiePair(cookieValue)) { + return SENSITIVE_DATA_SUBSTITUTE; + } final @NotNull String attributes = attributesSeparator < 0 ? "" : cookie.substring(attributesSeparator); return filterCookie(cookieValue, behavior, null) + attributes; } catch (Throwable ignored) { - return null; + return SENSITIVE_DATA_SUBSTITUTE; } } @@ -182,8 +185,12 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { final @NotNull String cookie, final @NotNull KeyValueCollectionBehavior behavior, final @Nullable List additionalSensitiveCookieNames) { + if (!isValidCookiePair(cookie)) { + return SENSITIVE_DATA_SUBSTITUTE; + } + final int separator = cookie.indexOf('='); - final @NotNull String name = separator < 0 ? cookie : cookie.substring(0, separator); + final @NotNull String name = cookie.substring(0, separator); final @NotNull String normalizedName = name.trim(); final boolean sensitive = containsTerm(normalizedName, SENSITIVE_DATA_KEYS) @@ -200,6 +207,11 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return cookie; } + private static boolean isValidCookiePair(final @NotNull String cookie) { + final int separator = cookie.indexOf('='); + return separator >= 0 && !cookie.substring(0, separator).trim().isEmpty(); + } + public static @NotNull Map filterHeaders( final @NotNull Map headers, final @NotNull KeyValueCollectionBehavior behavior) { diff --git a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt index 3eb3401a32d..2a7914628dc 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -97,6 +97,18 @@ class HttpUtilsTest { .isEqualTo("theme=dark; sessionId=[Filtered]; language=[Filtered]") } + @Test + fun `cookie filter preserves empty and padded base64 values`() { + assertThat( + HttpUtils.filterCookies( + "empty=; data=YWJjZA==", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("empty=; data=YWJjZA==") + } + @Test fun `cookie filter uses only the first equals separator`() { assertThat( @@ -109,6 +121,30 @@ class HttpUtilsTest { .isEqualTo("theme=dark=contrast; token=[Filtered]") } + @Test + fun `cookie filter replaces malformed pairs without discarding valid pairs`() { + assertThat( + HttpUtils.filterCookies( + "theme=dark; opaque; =secret; empty=; sessionId=secret", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark;[Filtered];[Filtered]; empty=; sessionId=[Filtered]") + } + + @Test + fun `cookie allow list never exposes malformed pairs`() { + assertThat( + HttpUtils.filterCookies( + "theme=dark; opaque; =secret", + KeyValueCollectionBehavior.allowList("theme", "opaque"), + emptyList(), + ) + ) + .isEqualTo("theme=dark;[Filtered];[Filtered]") + } + @Test fun `set cookie filter preserves attributes`() { assertThat( @@ -120,6 +156,26 @@ class HttpUtilsTest { .isEqualTo("sessionId=[Filtered]; Path=/; HttpOnly; SameSite=Lax") } + @Test + fun `set cookie filter preserves empty and padded base64 values`() { + assertThat( + HttpUtils.filterSetCookie( + "data=YWJjZA==; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=3600; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo( + "data=YWJjZA==; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=3600; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax" + ) + assertThat( + HttpUtils.filterSetCookie( + "empty=; Path=/", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("empty=; Path=/") + } + @Test fun `set cookie allow list retains allowed non-sensitive value and attributes`() { assertThat( @@ -131,6 +187,35 @@ class HttpUtilsTest { .isEqualTo("theme=dark; Path=/; Secure") } + @Test + fun `set cookie filter replaces malformed cookie pair and discards attributes`() { + assertThat( + HttpUtils.filterSetCookie( + "opaque; Path=/; HttpOnly", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("[Filtered]") + assertThat( + HttpUtils.filterSetCookie( + "=secret; Path=/; HttpOnly", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `set cookie allow list never exposes malformed cookie pair`() { + assertThat( + HttpUtils.filterSetCookie( + "opaque; Path=/; HttpOnly", + KeyValueCollectionBehavior.allowList("opaque"), + ) + ) + .isEqualTo("[Filtered]") + } + @Test fun `set cookie filter disables collection in off mode`() { assertThat( From 9da9baadb7be4555146a3aab1296018b5cf42735 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 24 Jul 2026 05:50:11 +0200 Subject: [PATCH 21/63] test(okhttp): Return headers from response mocks Keep mocked OkHttp responses consistent with the non-null headers contract so failed-request capture can inspect response cookies. Co-Authored-By: Claude --- .../src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt index 5570e37787b..cadb615ad94 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt @@ -22,6 +22,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import okhttp3.Headers import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -382,6 +383,7 @@ class SentryOkHttpEventTest { val sut = fixture.getSut() val clientErrorResponse = mock() whenever(clientErrorResponse.request).thenReturn(fixture.mockRequest) + whenever(clientErrorResponse.headers).thenReturn(Headers.headersOf()) sut.setClientErrorResponse(clientErrorResponse) verify(fixture.scopes, never()).captureEvent(any(), any()) sut.finish() @@ -403,6 +405,7 @@ class SentryOkHttpEventTest { val sut = fixture.getSut(currentSpan = null) val clientErrorResponse = mock() whenever(clientErrorResponse.request).thenReturn(fixture.mockRequest) + whenever(clientErrorResponse.headers).thenReturn(Headers.headersOf()) sut.setClientErrorResponse(clientErrorResponse) verify(fixture.scopes, never()).captureEvent(any(), any()) sut.finish() From 530d28d570d4caabbc8cb631c0bf3aa7f12b2441 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 24 Jul 2026 08:01:55 +0200 Subject: [PATCH 22/63] fix(android): Scope device info cache to SDK options Store DeviceInfoUtil on each SentryAndroidOptions instance so repeated SDK initializations cannot reuse stale collection policy or Android services. Preserve lazy initialization while allowing old and new clients to retain their own device context. Refs #5666 Co-Authored-By: Claude --- .../api/sentry-android-core.api | 1 - .../sentry/android/core/DeviceInfoUtil.java | 23 +--------- .../android/core/SentryAndroidOptions.java | 16 +++++++ .../ApplicationExitInfoEventProcessorTest.kt | 1 - .../core/DefaultAndroidEventProcessorTest.kt | 1 - .../sentry/android/core/DeviceInfoUtilTest.kt | 42 ++++++++++++++++++- .../android/core/InternalSentrySdkTest.kt | 1 - 7 files changed, 58 insertions(+), 27 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index adebedf2700..2e965ca6e27 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -282,7 +282,6 @@ public final class io/sentry/android/core/DeviceInfoUtil { public fun getSplitApksInfo ()Lio/sentry/android/core/ContextUtils$SplitApksInfo; public fun getTotalMemory ()Ljava/lang/Long; public static fun isCharging (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Boolean; - public static fun resetInstance ()V } public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : io/sentry/Integration, java/io/Closeable { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index d988cbd090e..397403ae9ad 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -15,7 +15,6 @@ import android.os.SystemClock; import android.util.DisplayMetrics; import io.sentry.DateUtils; -import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.CpuInfoUtils; @@ -23,7 +22,6 @@ import io.sentry.android.core.internal.util.RootChecker; import io.sentry.protocol.Device; import io.sentry.protocol.OperatingSystem; -import io.sentry.util.AutoClosableReentrantLock; import java.io.File; import java.util.Calendar; import java.util.Collections; @@ -34,17 +32,10 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal public final class DeviceInfoUtil { - @SuppressLint("StaticFieldLeak") - private static volatile DeviceInfoUtil instance; - - private static final @NotNull AutoClosableReentrantLock staticLock = - new AutoClosableReentrantLock(); - private final @NotNull Context context; private final @NotNull SentryAndroidOptions options; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -80,19 +71,7 @@ public DeviceInfoUtil( @NotNull public static DeviceInfoUtil getInstance( final @NotNull Context context, final @NotNull SentryAndroidOptions options) { - if (instance == null) { - try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { - if (instance == null) { - instance = new DeviceInfoUtil(ContextUtils.getApplicationContext(context), options); - } - } - } - return instance; - } - - @TestOnly - public static void resetInstance() { - instance = null; + return options.getOrCreateDeviceInfoUtil(context); } // we can get some inspiration here diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 615db97a28d..1a4f8483af8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -3,8 +3,10 @@ import android.app.Activity; import android.app.ActivityManager; import android.app.ApplicationExitInfo; +import android.content.Context; import io.sentry.Hint; import io.sentry.IScope; +import io.sentry.ISentryLifecycleToken; import io.sentry.ISpan; import io.sentry.Sentry; import io.sentry.SentryEvent; @@ -141,6 +143,8 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enables or disables collecting of external storage context. */ private boolean collectExternalStorageContext = false; + private volatile @Nullable DeviceInfoUtil deviceInfoUtil; + /** * Controls how many seconds to wait for sending events in case there were Startup Crashes in the * previous run. Sentry SDKs normally send events from a background queue, but in the case of @@ -200,6 +204,18 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enable or disable intent extras reporting for system event breadcrumbs. Default is false. */ private boolean enableSystemEventBreadcrumbsExtras = false; + @NotNull + DeviceInfoUtil getOrCreateDeviceInfoUtil(final @NotNull Context context) { + if (deviceInfoUtil == null) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (deviceInfoUtil == null) { + deviceInfoUtil = new DeviceInfoUtil(ContextUtils.getApplicationContext(context), this); + } + } + } + return deviceInfoUtil; + } + public interface BeforeCaptureCallback { /** diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index 7eaa269f39f..012013cafd5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -197,7 +197,6 @@ class ApplicationExitInfoEventProcessorTest { @BeforeTest fun `set up`() { - DeviceInfoUtil.resetInstance() fixture.context = ApplicationProvider.getApplicationContext() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index fbcd20b99fb..eab6ceacc13 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -87,7 +87,6 @@ class DefaultAndroidEventProcessorTest { fun `set up`() { context = ApplicationProvider.getApplicationContext() AppState.getInstance().resetInstance() - DeviceInfoUtil.resetInstance() CpuInfoUtils.getInstance().clear() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index 49c828b551e..3cd9e079da9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -15,7 +15,9 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertSame import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -32,7 +34,45 @@ class DeviceInfoUtilTest { .putExtra(BatteryManager.EXTRA_LEVEL, 75) .putExtra(BatteryManager.EXTRA_PLUGGED, 0) ) - DeviceInfoUtil.resetInstance() + } + + @Test + fun `same options reuse device info util`() { + val options = SentryAndroidOptions() + + val first = DeviceInfoUtil.getInstance(context, options) + val second = DeviceInfoUtil.getInstance(context, options) + + assertSame(first, second) + } + + @Test + fun `different options use isolated device info utils`() { + val enabledOptions = + SentryAndroidOptions().apply { + dataCollection.setUserInfo(true) + isCollectAdditionalContext = true + isEnableRootCheck = true + } + val disabledOptions = + SentryAndroidOptions().apply { + dataCollection.setUserInfo(false) + isCollectAdditionalContext = false + isEnableRootCheck = false + } + + val enabled = DeviceInfoUtil.getInstance(context, enabledOptions) + val disabled = DeviceInfoUtil.getInstance(context, disabledOptions) + val enabledDevice = enabled.collectDeviceInformation(true, false) + val disabledDevice = disabled.collectDeviceInformation(true, false) + + assertNotSame(enabled, disabled) + assertNotNull(enabledDevice.id) + assertNotNull(enabledDevice.storageSize) + assertNotNull(enabled.operatingSystem.isRooted) + assertNull(disabledDevice.id) + assertNull(disabledDevice.storageSize) + assertNull(disabled.operatingSystem.isRooted) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index 8c552a8b633..bd689c6a453 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -231,7 +231,6 @@ class InternalSentrySdkTest { fun `set up`() { Sentry.close() context = ApplicationProvider.getApplicationContext() - DeviceInfoUtil.resetInstance() } @Test From f83d56c0b8f5e7af8e197a169b342c3da9f02822 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 24 Jul 2026 14:28:09 +0200 Subject: [PATCH 23/63] 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()) } } From b27d61d218059360b2d9482e87a79b6bac6aeeae Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 24 Jul 2026 15:00:13 +0200 Subject: [PATCH 24/63] fix(android): Apply user info policy to distinct ID Generate the default Android installation ID after programmatic configuration and only when the resolved user information policy allows it. Preserve custom distinct IDs and keep legacy behavior when Data Collection is absent. Refs #5666 Co-Authored-By: Claude --- .../core/AndroidOptionsInitializer.java | 8 ---- .../io/sentry/android/core/SentryAndroid.java | 8 ++++ .../core/AndroidOptionsInitializerTest.kt | 44 +++++++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 9cc5cb3df0f..2a60e5750d7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -471,14 +471,6 @@ private static void readDefaultOptionValues( options.addInAppInclude(packageName); } } - - if (options.getDistinctId() == null) { - try { - options.setDistinctId(Installation.id(context)); - } catch (RuntimeException e) { - options.getLogger().log(SentryLevel.ERROR, "Could not generate distinct Id.", e); - } - } } /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index f27259fd635..150569dcecf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -149,6 +149,14 @@ public static void init( "Error in the 'OptionsConfiguration.configure' callback.", t); } + if (options.getDistinctId() == null + && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { + try { + options.setDistinctId(Installation.id(context)); + } catch (RuntimeException e) { + options.getLogger().log(SentryLevel.ERROR, "Could not generate distinct Id.", e); + } + } // if SentryPerformanceProvider was disabled or removed, // we set the app start / sdk init time here instead diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index f8724d286f8..068b0964bcc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -111,6 +111,12 @@ class AndroidOptionsInitializerTest { ) sentryOptions.configureOptions() + if ( + sentryOptions.distinctId == null && + sentryOptions.dataCollectionResolver.isUserInfoWithLegacyAlways + ) { + sentryOptions.distinctId = Installation.id(if (useRealContext) context else mockContext) + } AndroidOptionsInitializer.initializeIntegrationsAndProcessors( sentryOptions, if (useRealContext) context else mockContext, @@ -349,6 +355,44 @@ class AndroidOptionsInitializerTest { installation.deleteOnExit() } + @Test + fun `init should not set generated distinct id when user info is disabled`() { + fixture.initSut(configureOptions = { dataCollection.setUserInfo(false) }) + + assertNull(fixture.sentryOptions.distinctId) + } + + @Test + fun `init should set generated distinct id when user info is enabled`() { + fixture.initSut(configureOptions = { dataCollection.setUserInfo(true) }) + + assertNotNull(fixture.sentryOptions.distinctId) + } + + @Test + fun `init should preserve explicit distinct id when user info is disabled`() { + fixture.initSut( + configureOptions = { + dataCollection.setUserInfo(false) + distinctId = "custom-id" + } + ) + + assertEquals("custom-id", fixture.sentryOptions.distinctId) + } + + @Test + fun `init should set generated distinct id when explicit value is null`() { + fixture.initSut( + configureOptions = { + dataCollection.setUserInfo(true) + distinctId = null + } + ) + + assertNotNull(fixture.sentryOptions.distinctId) + } + @Test fun `init should set proguard uuid id on start`() { fixture.initSut( From 1786e17a864c2415bb4b5c3c0b25330b7d273af9 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 24 Jul 2026 15:33:18 +0200 Subject: [PATCH 25/63] fix(spring): Bind Data Collection key-value policies Make KeyValueCollectionBehavior JavaBean-bindable so Spring Boot properties correctly configure cookie, header, and query policies. Cover binding across all supported Spring Boot generations. Co-Authored-By: Claude --- .../boot4/SentryAutoConfigurationTest.kt | 30 +++++++++++++++++++ .../jakarta/SentryAutoConfigurationTest.kt | 30 +++++++++++++++++++ .../boot/SentryAutoConfigurationTest.kt | 30 +++++++++++++++++++ sentry/api/sentry.api | 3 ++ .../io/sentry/KeyValueCollectionBehavior.java | 19 +++++++++--- .../sentry/KeyValueCollectionBehaviorTest.kt | 21 +++++++++++++ 6 files changed, 129 insertions(+), 4 deletions(-) diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt index ef1f12aeecf..8e4cc6119d2 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -12,6 +12,7 @@ import io.sentry.IProfileConverter import io.sentry.IScopes import io.sentry.ITransportFactory import io.sentry.Integration +import io.sentry.KeyValueCollectionBehavior import io.sentry.NoOpContinuousProfiler import io.sentry.NoOpProfileConverter import io.sentry.NoOpTransportFactory @@ -306,6 +307,35 @@ class SentryAutoConfigurationTest { } } + @Test + fun `data collection key value properties are applied to SentryOptions`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.data-collection.cookies.mode=off", + "sentry.data-collection.query-params.mode=allow-list", + "sentry.data-collection.query-params.terms=page,sort", + "sentry.data-collection.http-headers.request.mode=deny-list", + "sentry.data-collection.http-headers.request.terms=forwarded,-ip", + "sentry.data-collection.http-headers.response.mode=allow-list", + "sentry.data-collection.http-headers.response.terms=content-type,x-request-id", + ) + .run { + val dataCollection = it.getBean(SentryProperties::class.java).dataCollection + assertThat(dataCollection.cookies!!.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) + assertThat(dataCollection.queryParams!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.queryParams!!.terms).containsExactly("page", "sort") + assertThat(dataCollection.httpHeaders.request!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(dataCollection.httpHeaders.request!!.terms).containsExactly("forwarded", "-ip") + assertThat(dataCollection.httpHeaders.response!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.httpHeaders.response!!.terms) + .containsExactly("content-type", "x-request-id") + } + } + @Test fun `when tracePropagationTargets are not set, default is returned`() { contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index 91677d16b4e..308e623cba2 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -13,6 +13,7 @@ import io.sentry.IProfileConverter import io.sentry.IScopes import io.sentry.ITransportFactory import io.sentry.Integration +import io.sentry.KeyValueCollectionBehavior import io.sentry.NoOpContinuousProfiler import io.sentry.NoOpProfileConverter import io.sentry.NoOpTransportFactory @@ -314,6 +315,35 @@ class SentryAutoConfigurationTest { } } + @Test + fun `data collection key value properties are applied to SentryOptions`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.data-collection.cookies.mode=off", + "sentry.data-collection.query-params.mode=allow-list", + "sentry.data-collection.query-params.terms=page,sort", + "sentry.data-collection.http-headers.request.mode=deny-list", + "sentry.data-collection.http-headers.request.terms=forwarded,-ip", + "sentry.data-collection.http-headers.response.mode=allow-list", + "sentry.data-collection.http-headers.response.terms=content-type,x-request-id", + ) + .run { + val dataCollection = it.getBean(SentryProperties::class.java).dataCollection + assertThat(dataCollection.cookies!!.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) + assertThat(dataCollection.queryParams!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.queryParams!!.terms).containsExactly("page", "sort") + assertThat(dataCollection.httpHeaders.request!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(dataCollection.httpHeaders.request!!.terms).containsExactly("forwarded", "-ip") + assertThat(dataCollection.httpHeaders.response!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.httpHeaders.response!!.terms) + .containsExactly("content-type", "x-request-id") + } + } + @Test fun `when tracePropagationTargets are not set, default is returned`() { contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index d9e598d0473..5eebd1bcc6b 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -13,6 +13,7 @@ import io.sentry.IProfileConverter import io.sentry.IScopes import io.sentry.ITransportFactory import io.sentry.Integration +import io.sentry.KeyValueCollectionBehavior import io.sentry.NoOpContinuousProfiler import io.sentry.NoOpProfileConverter import io.sentry.NoOpTransportFactory @@ -312,6 +313,35 @@ class SentryAutoConfigurationTest { } } + @Test + fun `data collection key value properties are applied to SentryOptions`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.data-collection.cookies.mode=off", + "sentry.data-collection.query-params.mode=allow-list", + "sentry.data-collection.query-params.terms=page,sort", + "sentry.data-collection.http-headers.request.mode=deny-list", + "sentry.data-collection.http-headers.request.terms=forwarded,-ip", + "sentry.data-collection.http-headers.response.mode=allow-list", + "sentry.data-collection.http-headers.response.terms=content-type,x-request-id", + ) + .run { + val dataCollection = it.getBean(SentryProperties::class.java).dataCollection + assertThat(dataCollection.cookies!!.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) + assertThat(dataCollection.queryParams!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.queryParams!!.terms).containsExactly("page", "sort") + assertThat(dataCollection.httpHeaders.request!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(dataCollection.httpHeaders.request!!.terms).containsExactly("forwarded", "-ip") + assertThat(dataCollection.httpHeaders.response!!.mode) + .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(dataCollection.httpHeaders.response!!.terms) + .containsExactly("content-type", "x-request-id") + } + } + @Test fun `when tracePropagationTargets are not set, default is returned`() { dsnEnabledRunner.run { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2a4ab4f0c1a..6e61380f81b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1447,6 +1447,7 @@ public abstract interface class io/sentry/JsonUnknown { } public final class io/sentry/KeyValueCollectionBehavior { + public fun ()V public static fun allowList ([Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior; public static fun denyList ([Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior; public fun equals (Ljava/lang/Object;)Z @@ -1454,6 +1455,8 @@ public final class io/sentry/KeyValueCollectionBehavior { public fun getTerms ()Ljava/util/List; public fun hashCode ()I public static fun off ()Lio/sentry/KeyValueCollectionBehavior; + public fun setMode (Lio/sentry/KeyValueCollectionBehavior$Mode;)V + public fun setTerms (Ljava/util/List;)V } public final class io/sentry/KeyValueCollectionBehavior$Mode : java/lang/Enum { diff --git a/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java b/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java index d9daeb9bc02..35cc0896719 100644 --- a/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java +++ b/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java @@ -20,12 +20,15 @@ public enum Mode { ALLOW_LIST } - private final @NotNull Mode mode; - private final @NotNull List terms; + private @NotNull Mode mode = Mode.DENY_LIST; + private @NotNull List terms = Collections.emptyList(); + + /** Creates a behavior that collects values using the built-in sensitive deny-list. */ + public KeyValueCollectionBehavior() {} private KeyValueCollectionBehavior(final @NotNull Mode mode, final @NotNull List terms) { - this.mode = mode; - this.terms = Collections.unmodifiableList(new ArrayList<>(terms)); + setMode(mode); + setTerms(terms); } /** Disables collection of the category. */ @@ -53,10 +56,18 @@ private KeyValueCollectionBehavior(final @NotNull Mode mode, final @NotNull List return mode; } + public void setMode(final @NotNull Mode mode) { + this.mode = mode; + } + public @NotNull List getTerms() { return terms; } + public void setTerms(final @NotNull List terms) { + this.terms = Collections.unmodifiableList(new ArrayList<>(terms)); + } + @Override public boolean equals(final Object other) { if (this == other) { diff --git a/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt b/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt index 7e014eec504..09cd92f4fce 100644 --- a/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt +++ b/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt @@ -4,6 +4,27 @@ import com.google.common.truth.Truth.assertThat import kotlin.test.Test class KeyValueCollectionBehaviorTest { + @Test + fun `default constructor uses deny list with no terms`() { + val behavior = KeyValueCollectionBehavior() + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(behavior.terms).isEmpty() + } + + @Test + fun `setters update mode and defensively copy terms`() { + val terms = mutableListOf("token") + val behavior = KeyValueCollectionBehavior() + + behavior.mode = KeyValueCollectionBehavior.Mode.ALLOW_LIST + behavior.terms = terms + terms[0] = "password" + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(behavior.terms).containsExactly("token") + } + @Test fun `off has no terms`() { val behavior = KeyValueCollectionBehavior.off() From b13daf86577a47890a293c2ffdf6c5461bc6edba Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 10 Aug 2026 14:37:57 +0200 Subject: [PATCH 26/63] ref(core): Rename URL query parameter option Align the public Java API with the canonical Data Collection specification before release. The option has not shipped, so replace the old name without a compatibility alias. Refs #5666 Co-Authored-By: Claude --- sentry/api/sentry.api | 4 ++-- sentry/src/main/java/io/sentry/DataCollection.java | 12 ++++++------ sentry/src/test/java/io/sentry/DataCollectionTest.kt | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e1b723ac462..c8cb44ef590 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -390,12 +390,12 @@ public final class io/sentry/DataCollection { public fun getGraphql ()Lio/sentry/DataCollection$Graphql; public fun getHttpBodies ()Ljava/util/Set; public fun getHttpHeaders ()Lio/sentry/DataCollection$HttpHeaders; - public fun getQueryParams ()Lio/sentry/KeyValueCollectionBehavior; + public fun getUrlQueryParams ()Lio/sentry/KeyValueCollectionBehavior; public fun getUserInfo ()Ljava/lang/Boolean; public fun setCookies (Lio/sentry/KeyValueCollectionBehavior;)V public fun setDatabaseQueryData (Z)V public fun setHttpBodies (Ljava/util/Set;)V - public fun setQueryParams (Lio/sentry/KeyValueCollectionBehavior;)V + public fun setUrlQueryParams (Lio/sentry/KeyValueCollectionBehavior;)V public fun setUserInfo (Z)V } diff --git a/sentry/src/main/java/io/sentry/DataCollection.java b/sentry/src/main/java/io/sentry/DataCollection.java index d46d2d262a8..c798dfa032a 100644 --- a/sentry/src/main/java/io/sentry/DataCollection.java +++ b/sentry/src/main/java/io/sentry/DataCollection.java @@ -13,7 +13,7 @@ public final class DataCollection { private boolean overridden; private @Nullable Boolean userInfo; private @Nullable KeyValueCollectionBehavior cookies; - private @Nullable KeyValueCollectionBehavior queryParams; + private @Nullable KeyValueCollectionBehavior urlQueryParams; private @Nullable Set httpBodies; private @Nullable Boolean databaseQueryData; private final @NotNull HttpHeaders httpHeaders = new HttpHeaders(); @@ -43,12 +43,12 @@ public void setCookies(final @Nullable KeyValueCollectionBehavior cookies) { this.cookies = cookies; } - public @Nullable KeyValueCollectionBehavior getQueryParams() { - return queryParams; + public @Nullable KeyValueCollectionBehavior getUrlQueryParams() { + return urlQueryParams; } - public void setQueryParams(final @Nullable KeyValueCollectionBehavior queryParams) { - this.queryParams = queryParams; + public void setUrlQueryParams(final @Nullable KeyValueCollectionBehavior urlQueryParams) { + this.urlQueryParams = urlQueryParams; } public @Nullable Set getHttpBodies() { @@ -85,7 +85,7 @@ boolean isExplicitlyConfigured() { return overridden || userInfo != null || cookies != null - || queryParams != null + || urlQueryParams != null || httpBodies != null || databaseQueryData != null || httpHeaders.hasOverrides() diff --git a/sentry/src/test/java/io/sentry/DataCollectionTest.kt b/sentry/src/test/java/io/sentry/DataCollectionTest.kt index 74cc4b8222c..ffb47458d7a 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionTest.kt @@ -11,7 +11,7 @@ class DataCollectionTest { assertThat(dataCollection.userInfo).isNull() assertThat(dataCollection.cookies).isNull() - assertThat(dataCollection.queryParams).isNull() + assertThat(dataCollection.urlQueryParams).isNull() assertThat(dataCollection.httpBodies).isNull() assertThat(dataCollection.databaseQueryData).isNull() assertThat(dataCollection.httpHeaders.request).isNull() From 6a66949afd186ef53f165b06e15e0faa5b460ded Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 10 Aug 2026 14:39:09 +0200 Subject: [PATCH 27/63] ref(core): Rename URL query parameter resolver Keep the internal resolver and its tests aligned with the canonical public Data Collection option name. Refs #5666 Co-Authored-By: Claude --- sentry/api/sentry.api | 2 +- .../main/java/io/sentry/DataCollectionResolver.java | 4 ++-- .../java/io/sentry/DataCollectionResolverTest.kt | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c0b0a4b6f76..471e94d1864 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -419,7 +419,7 @@ public final class io/sentry/DataCollectionResolver { public fun getCookies ()Lio/sentry/KeyValueCollectionBehavior; public fun getHttpRequestHeaders ()Lio/sentry/KeyValueCollectionBehavior; public fun getHttpResponseHeaders ()Lio/sentry/KeyValueCollectionBehavior; - public fun getQueryParams ()Lio/sentry/KeyValueCollectionBehavior; + public fun getUrlQueryParams ()Lio/sentry/KeyValueCollectionBehavior; public fun isDataCollectionConfigured ()Z public fun isDatabaseQueryData ()Z public fun isGraphqlDocument ()Z diff --git a/sentry/src/main/java/io/sentry/DataCollectionResolver.java b/sentry/src/main/java/io/sentry/DataCollectionResolver.java index 3063268f0f7..6b77c3ce4d2 100644 --- a/sentry/src/main/java/io/sentry/DataCollectionResolver.java +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -52,8 +52,8 @@ public boolean isGraphqlVariables() { return options.isSendDefaultPii() ? EMPTY_DENY_LIST : OFF; } - public @NotNull KeyValueCollectionBehavior getQueryParams() { - return explicitOrEmptyDenyList(options.getDataCollection().getQueryParams()); + public @NotNull KeyValueCollectionBehavior getUrlQueryParams() { + return explicitOrEmptyDenyList(options.getDataCollection().getUrlQueryParams()); } public @NotNull KeyValueCollectionBehavior getHttpRequestHeaders() { diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt index 73f1ba93dc9..1ad9b4c8112 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -25,7 +25,7 @@ class DataCollectionResolverTest { assertThat(options.dataCollectionResolver.isDataCollectionConfigured).isFalse() - options.dataCollection.queryParams = KeyValueCollectionBehavior.denyList() + options.dataCollection.urlQueryParams = KeyValueCollectionBehavior.denyList() assertThat(options.dataCollectionResolver.isDataCollectionConfigured).isTrue() } @@ -125,21 +125,21 @@ class DataCollectionResolverTest { } @Test - fun `query params use default deny list when unset`() { + fun `URL query params use default deny list when unset`() { val options = SentryOptions() - assertThat(options.dataCollectionResolver.queryParams) + assertThat(options.dataCollectionResolver.urlQueryParams) .isEqualTo(KeyValueCollectionBehavior.denyList()) } @Test - fun `query params override takes precedence`() { + fun `URL query params override takes precedence`() { val options = SentryOptions() val behavior = KeyValueCollectionBehavior.allowList("language", "theme") - options.dataCollection.queryParams = behavior + options.dataCollection.urlQueryParams = behavior - assertThat(options.dataCollectionResolver.queryParams).isEqualTo(behavior) + assertThat(options.dataCollectionResolver.urlQueryParams).isEqualTo(behavior) } @Test From 11e4cc0dd1ea8a2cdb752978eab71cb8a2ad2319 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 10 Aug 2026 14:41:36 +0200 Subject: [PATCH 28/63] ref(http): Use URL query parameter option name Update URL filtering and integration coverage to consume the renamed canonical Data Collection option. Refs #5666 Co-Authored-By: Claude --- .../src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt | 2 +- .../servlet/SentryRequestHttpServletRequestProcessorTest.kt | 2 +- sentry/src/main/java/io/sentry/util/UrlUtils.java | 2 +- sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt index 2d310345050..b5d01f4f7e3 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -70,7 +70,7 @@ class OpenTelemetryAttributesExtractorTest { @Test fun `data collection can disable URL query attributes`() { - fixture.options.dataCollection.queryParams = KeyValueCollectionBehavior.off() + fixture.options.dataCollection.urlQueryParams = KeyValueCollectionBehavior.off() givenAttributes( mapOf( HttpAttributes.HTTP_REQUEST_METHOD to "GET", diff --git a/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt index f6bd09894a8..c3861ed145e 100644 --- a/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-servlet/src/test/kotlin/io/sentry/servlet/SentryRequestHttpServletRequestProcessorTest.kt @@ -57,7 +57,7 @@ class SentryRequestHttpServletRequestProcessorTest { MockMvcRequestBuilders.get(URI.create("http://example.com?name=value")) .buildRequest(MockServletContext()) val options = - SentryOptions().also { it.dataCollection.queryParams = KeyValueCollectionBehavior.off() } + SentryOptions().also { it.dataCollection.urlQueryParams = KeyValueCollectionBehavior.off() } val event = SentryEvent() SentryRequestHttpServletRequestProcessor(request, options).process(event, Hint()) diff --git a/sentry/src/main/java/io/sentry/util/UrlUtils.java b/sentry/src/main/java/io/sentry/util/UrlUtils.java index 6dc33795b1d..f8fdcd273a6 100644 --- a/sentry/src/main/java/io/sentry/util/UrlUtils.java +++ b/sentry/src/main/java/io/sentry/util/UrlUtils.java @@ -50,7 +50,7 @@ public final class UrlUtils { public static @Nullable String filterQueryParams( final @Nullable String query, final @NotNull DataCollectionResolver resolver) { return resolver.isDataCollectionConfigured() - ? HttpUtils.filterQueryParams(query, resolver.getQueryParams()) + ? HttpUtils.filterQueryParams(query, resolver.getUrlQueryParams()) : query; } diff --git a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt index 91065f6e50e..18c9444917c 100644 --- a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt @@ -54,7 +54,7 @@ class UrlUtilsTest { @Test fun `resolver aware helpers remove query values in off mode`() { val options = - SentryOptions().also { it.dataCollection.queryParams = KeyValueCollectionBehavior.off() } + SentryOptions().also { it.dataCollection.urlQueryParams = KeyValueCollectionBehavior.off() } val details = UrlUtils.parse("https://example.com?name=value", options.dataCollectionResolver) val request = Request() val breadcrumb = From c030de20b82446cd3e2dd905128ab40ab1118ec4 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 10 Aug 2026 14:43:53 +0200 Subject: [PATCH 29/63] test(spring): Bind URL query parameter policies Use the canonical URL query parameter property name in Spring Boot 2, 3, and 4 binding coverage. Refs #5666 Co-Authored-By: Claude --- .../io/sentry/spring/boot4/SentryAutoConfigurationTest.kt | 8 ++++---- .../spring/boot/jakarta/SentryAutoConfigurationTest.kt | 8 ++++---- .../io/sentry/spring/boot/SentryAutoConfigurationTest.kt | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt index 8e4cc6119d2..e9a33755334 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -313,8 +313,8 @@ class SentryAutoConfigurationTest { .withPropertyValues( "sentry.dsn=http://key@localhost/proj", "sentry.data-collection.cookies.mode=off", - "sentry.data-collection.query-params.mode=allow-list", - "sentry.data-collection.query-params.terms=page,sort", + "sentry.data-collection.url-query-params.mode=allow-list", + "sentry.data-collection.url-query-params.terms=page,sort", "sentry.data-collection.http-headers.request.mode=deny-list", "sentry.data-collection.http-headers.request.terms=forwarded,-ip", "sentry.data-collection.http-headers.response.mode=allow-list", @@ -323,9 +323,9 @@ class SentryAutoConfigurationTest { .run { val dataCollection = it.getBean(SentryProperties::class.java).dataCollection assertThat(dataCollection.cookies!!.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) - assertThat(dataCollection.queryParams!!.mode) + assertThat(dataCollection.urlQueryParams!!.mode) .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) - assertThat(dataCollection.queryParams!!.terms).containsExactly("page", "sort") + assertThat(dataCollection.urlQueryParams!!.terms).containsExactly("page", "sort") assertThat(dataCollection.httpHeaders.request!!.mode) .isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) assertThat(dataCollection.httpHeaders.request!!.terms).containsExactly("forwarded", "-ip") diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index 308e623cba2..b3abc45c4b1 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -321,8 +321,8 @@ class SentryAutoConfigurationTest { .withPropertyValues( "sentry.dsn=http://key@localhost/proj", "sentry.data-collection.cookies.mode=off", - "sentry.data-collection.query-params.mode=allow-list", - "sentry.data-collection.query-params.terms=page,sort", + "sentry.data-collection.url-query-params.mode=allow-list", + "sentry.data-collection.url-query-params.terms=page,sort", "sentry.data-collection.http-headers.request.mode=deny-list", "sentry.data-collection.http-headers.request.terms=forwarded,-ip", "sentry.data-collection.http-headers.response.mode=allow-list", @@ -331,9 +331,9 @@ class SentryAutoConfigurationTest { .run { val dataCollection = it.getBean(SentryProperties::class.java).dataCollection assertThat(dataCollection.cookies!!.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) - assertThat(dataCollection.queryParams!!.mode) + assertThat(dataCollection.urlQueryParams!!.mode) .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) - assertThat(dataCollection.queryParams!!.terms).containsExactly("page", "sort") + assertThat(dataCollection.urlQueryParams!!.terms).containsExactly("page", "sort") assertThat(dataCollection.httpHeaders.request!!.mode) .isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) assertThat(dataCollection.httpHeaders.request!!.terms).containsExactly("forwarded", "-ip") diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index 5eebd1bcc6b..834440faf1c 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -319,8 +319,8 @@ class SentryAutoConfigurationTest { .withPropertyValues( "sentry.dsn=http://key@localhost/proj", "sentry.data-collection.cookies.mode=off", - "sentry.data-collection.query-params.mode=allow-list", - "sentry.data-collection.query-params.terms=page,sort", + "sentry.data-collection.url-query-params.mode=allow-list", + "sentry.data-collection.url-query-params.terms=page,sort", "sentry.data-collection.http-headers.request.mode=deny-list", "sentry.data-collection.http-headers.request.terms=forwarded,-ip", "sentry.data-collection.http-headers.response.mode=allow-list", @@ -329,9 +329,9 @@ class SentryAutoConfigurationTest { .run { val dataCollection = it.getBean(SentryProperties::class.java).dataCollection assertThat(dataCollection.cookies!!.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) - assertThat(dataCollection.queryParams!!.mode) + assertThat(dataCollection.urlQueryParams!!.mode) .isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) - assertThat(dataCollection.queryParams!!.terms).containsExactly("page", "sort") + assertThat(dataCollection.urlQueryParams!!.terms).containsExactly("page", "sort") assertThat(dataCollection.httpHeaders.request!!.mode) .isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) assertThat(dataCollection.httpHeaders.request!!.terms).containsExactly("forwarded", "-ip") From a40c2e495c4db8d3b86f03e31faebef46e35afcd Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 10 Aug 2026 16:56:46 +0200 Subject: [PATCH 30/63] fix(core): Preserve Data Collection on null setter Ignore null assignments so the always-present Data Collection configuration and its current values remain intact. Refs #5666 Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/SentryOptions.java | 4 +++- .../src/test/java/io/sentry/SentryOptionsTest.kt | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index bdafb889c2d..311db07cb32 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -1715,7 +1715,9 @@ public void setSendDefaultPii(boolean sendDefaultPii) { *

Passing an empty {@link DataCollection} opts into the documented data-collection defaults. */ public void setDataCollection(final @NotNull DataCollection dataCollection) { - this.dataCollection = dataCollection; + if (dataCollection != null) { + this.dataCollection = dataCollection; + } } /** diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 57fa9f507a6..d5c7e6f3c7e 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -67,6 +67,21 @@ class SentryOptionsTest { assertThat(options.dataCollection.userInfo).isFalse() } + @Test + fun `setting null data collection preserves the current instance`() { + val options = SentryOptions() + val dataCollection = DataCollection().apply { setUserInfo(false) } + options.dataCollection = dataCollection + + SentryOptions::class + .java + .getMethod("setDataCollection", DataCollection::class.java) + .invoke(options, null) + + assertThat(options.dataCollection).isSameInstanceAs(dataCollection) + assertThat(options.dataCollection.userInfo).isFalse() + } + @Test fun `when options is initialized, logger is not null`() { assertNotNull(SentryOptions().logger) From 44992bd270577d7e82e342ddda736e22911df292 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 12 Aug 2026 10:58:59 +0200 Subject: [PATCH 31/63] fix(android): Preserve installation ID independently of Data Collection Keep the Android installation ID available for distinct ID, user ID, device ID, and hybrid scope fallbacks regardless of the userInfo setting. Continue applying userInfo only to automatic user details such as IP addresses and remove the now-unused legacy-always resolver variant. Refs #5666 Co-Authored-By: Claude --- .../core/ApplicationExitInfoEventProcessor.java | 5 ++--- .../android/core/DefaultAndroidEventProcessor.java | 6 ++---- .../java/io/sentry/android/core/DeviceInfoUtil.java | 3 +-- .../io/sentry/android/core/InternalSentrySdk.java | 3 +-- .../java/io/sentry/android/core/SentryAndroid.java | 3 +-- .../android/core/AndroidOptionsInitializerTest.kt | 9 +++------ .../core/ApplicationExitInfoEventProcessorTest.kt | 8 ++++---- .../android/core/DefaultAndroidEventProcessorTest.kt | 4 ++-- .../java/io/sentry/android/core/DeviceInfoUtilTest.kt | 6 +++--- .../io/sentry/android/core/InternalSentrySdkTest.kt | 5 ++--- sentry/api/sentry.api | 1 - .../main/java/io/sentry/DataCollectionResolver.java | 4 ---- .../test/java/io/sentry/DataCollectionResolverTest.kt | 11 ----------- 13 files changed, 21 insertions(+), 47 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 62b32dd76ce..4f8df86f572 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -568,7 +568,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { } // userId should be set even if event is Cached as the userId is static and won't change anyway. - if (user.getId() == null && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { + if (user.getId() == null) { user.setId(getDeviceId()); } if (user.getIpAddress() == null && options.getDataCollectionResolver().isUserInfo()) { @@ -635,8 +635,7 @@ private void setDevice(final @NotNull SentryBaseEvent event) { device.setScreenDpi(displayMetrics.densityDpi); } - if (device.getId() == null - && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { + if (device.getId() == null) { device.setId(getDeviceId()); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 520706b352c..84e953ee69e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -175,7 +175,7 @@ private void mergeUser(final @NotNull SentryBaseEvent event) { } // userId should be set even if event is Cached as the userId is static and won't change anyway. - if (user.getId() == null && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { + if (user.getId() == null) { user.setId(Installation.id(context)); } if (user.getIpAddress() == null && options.getDataCollectionResolver().isUserInfo()) { @@ -374,9 +374,7 @@ private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { */ public @NotNull User getDefaultUser(final @NotNull Context context) { final @NotNull User user = new User(); - if (options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { - user.setId(Installation.id(context)); - } + user.setId(Installation.id(context)); return user; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 397403ae9ad..d96dc5cb387 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -109,8 +109,7 @@ public Device collectDeviceInformation( device.setBootTime(getBootTime()); device.setTimezone(getTimeZone()); - if (device.getId() == null - && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { + if (device.getId() == null) { device.setId(getDeviceId()); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 822a65727d0..2779f803a69 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -99,8 +99,7 @@ public static Map serializeScope( user = new User(); scope.setUser(user); } - if (user.getId() == null - && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { + if (user.getId() == null) { try { user.setId(Installation.id(context)); } catch (RuntimeException e) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 150569dcecf..a0dca224ccd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -149,8 +149,7 @@ public static void init( "Error in the 'OptionsConfiguration.configure' callback.", t); } - if (options.getDistinctId() == null - && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { + if (options.getDistinctId() == null) { try { options.setDistinctId(Installation.id(context)); } catch (RuntimeException e) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 068b0964bcc..3da83a9080b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -111,10 +111,7 @@ class AndroidOptionsInitializerTest { ) sentryOptions.configureOptions() - if ( - sentryOptions.distinctId == null && - sentryOptions.dataCollectionResolver.isUserInfoWithLegacyAlways - ) { + if (sentryOptions.distinctId == null) { sentryOptions.distinctId = Installation.id(if (useRealContext) context else mockContext) } AndroidOptionsInitializer.initializeIntegrationsAndProcessors( @@ -356,10 +353,10 @@ class AndroidOptionsInitializerTest { } @Test - fun `init should not set generated distinct id when user info is disabled`() { + fun `init should set generated distinct id when user info is disabled`() { fixture.initSut(configureOptions = { dataCollection.setUserInfo(false) }) - assertNull(fixture.sentryOptions.distinctId) + assertNotNull(fixture.sentryOptions.distinctId) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index 012013cafd5..c57728d9659 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -228,13 +228,13 @@ class ApplicationExitInfoEventProcessorTest { } @Test - fun `when user info is disabled, does not set device id`() { + fun `when user info is disabled, sets device id`() { fixture.options.dataCollection.setUserInfo(false) val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint()) val processed = processEvent(hint) - assertNull(processed.contexts.device!!.id) + assertNotNull(processed.contexts.device!!.id) } @Test @@ -477,7 +477,7 @@ class ApplicationExitInfoEventProcessorTest { } @Test - fun `when user info is disabled, does not set installation id for missing user id`() { + fun `when user info is disabled, sets installation id for missing user id`() { fixture.options.dataCollection.setUserInfo(false) val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) val original = SentryEvent() @@ -486,7 +486,7 @@ class ApplicationExitInfoEventProcessorTest { val processed = processor.process(original, hint) - assertNull(processed!!.user!!.id) + assertEquals(Installation.deviceId, processed!!.user!!.id) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index eab6ceacc13..460e0bbec1d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -285,7 +285,7 @@ class DefaultAndroidEventProcessorTest { } @Test - fun `when user info is disabled, does not set automatic user data`() { + fun `when user info is disabled, sets installation id but not automatic ip`() { fixture.options.dataCollection.setUserInfo(false) val sut = fixture.getSut(context, isSendDefaultPii = true) val event = SentryEvent().apply { user = User() } @@ -293,7 +293,7 @@ class DefaultAndroidEventProcessorTest { sut.process(event, Hint()) assertNotNull(event.user) { - assertNull(it.id) + assertNotNull(it.id) assertNull(it.ipAddress) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index 3cd9e079da9..fffdf6257ea 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -70,7 +70,7 @@ class DeviceInfoUtilTest { assertNotNull(enabledDevice.id) assertNotNull(enabledDevice.storageSize) assertNotNull(enabled.operatingSystem.isRooted) - assertNull(disabledDevice.id) + assertNotNull(disabledDevice.id) assertNull(disabledDevice.storageSize) assertNull(disabled.operatingSystem.isRooted) } @@ -94,12 +94,12 @@ class DeviceInfoUtilTest { } @Test - fun `does not set device id when user info is disabled`() { + fun `sets device id when user info is disabled`() { val options = SentryAndroidOptions().apply { dataCollection.setUserInfo(false) } val deviceInfo = DeviceInfoUtil.getInstance(context, options).collectDeviceInformation(false, false) - assertNull(deviceInfo.id) + assertNotNull(deviceInfo.id) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index bd689c6a453..137c2d233e3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -38,7 +38,6 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -327,14 +326,14 @@ class InternalSentrySdkTest { } @Test - fun `serializeScope does not provide fallback user id when user info is disabled`() { + fun `serializeScope provides fallback user id when user info is disabled`() { val options = SentryAndroidOptions().apply { dataCollection.setUserInfo(false) } val scope = Scope(options) scope.user = null val serializedScope = InternalSentrySdk.serializeScope(context, options, scope) - assertFalse((serializedScope["user"] as Map<*, *>).containsKey("id")) + assertTrue((serializedScope["user"] as Map<*, *>).containsKey("id")) } @Test diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 256a61990ea..3b17e9e2aa1 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -436,7 +436,6 @@ public final class io/sentry/DataCollectionResolver { public fun isOutgoingResponseBody ()Z public fun isOutgoingResponseBodyWithLegacyBodyGate ()Z public fun isUserInfo ()Z - public fun isUserInfoWithLegacyAlways ()Z } public final class io/sentry/DateUtils { diff --git a/sentry/src/main/java/io/sentry/DataCollectionResolver.java b/sentry/src/main/java/io/sentry/DataCollectionResolver.java index fba8149e723..e4234f6f111 100644 --- a/sentry/src/main/java/io/sentry/DataCollectionResolver.java +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -27,10 +27,6 @@ public boolean isUserInfo() { return explicitOrSendDefaultPii(options.getDataCollection().getUserInfo(), true); } - public boolean isUserInfoWithLegacyAlways() { - return explicitOrDefault(options.getDataCollection().getUserInfo(), true, true); - } - public boolean isDatabaseQueryData() { return explicitOrSendDefaultPii(options.getDataCollection().getDatabaseQueryData(), true); } diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt index 2bade51724e..8fb204c9d9f 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -55,17 +55,6 @@ class DataCollectionResolverTest { assertThat(options.dataCollectionResolver.isUserInfo).isTrue() } - @Test - fun `user info legacy always variant preserves collection when namespace is absent`() { - val options = SentryOptions().apply { isSendDefaultPii = false } - - assertThat(options.dataCollectionResolver.isUserInfoWithLegacyAlways).isTrue() - - options.dataCollection.setUserInfo(false) - - assertThat(options.dataCollectionResolver.isUserInfoWithLegacyAlways).isFalse() - } - @Test fun `omitted booleans use data collection defaults once namespace is explicit`() { val options = SentryOptions().apply { isSendDefaultPii = false } From cc72c0ca7b41b437175adb928ac4629425857bc9 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 24 Aug 2026 13:58:11 +0200 Subject: [PATCH 32/63] fix(ktor): Exclude query parameters from span descriptions Parse Ktor client request URLs through the shared URL utility so span descriptions omit query parameters and fragments. Keep the raw URL for trace propagation and avoid introducing query span data. Refs #5666 Co-Authored-By: Claude --- .../ktorClient/SentryKtorClientPlugin.kt | 19 ++++++++++--------- .../ktorClient/SentryKtorClientPluginTest.kt | 10 ++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt index 2f559f804fc..95cdfb5fdae 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientPlugin.kt @@ -24,6 +24,7 @@ import io.sentry.util.Platform import io.sentry.util.PropagationTargetsUtils import io.sentry.util.SpanUtils import io.sentry.util.TracingUtils +import io.sentry.util.UrlUtils import kotlinx.coroutines.withContext /** Configuration for the Sentry Ktor client plugin. */ @@ -98,20 +99,20 @@ public val SentryKtorClientPlugin: ClientPlugin = val requestSpanKey = AttributeKey("SentryRequestSpan") onRequest { request, _ -> + val effectiveScopes = if (forceScopes) scopes else Sentry.getCurrentScopes() request.attributes.put( requestStartTimestampKey, - (if (forceScopes) scopes else Sentry.getCurrentScopes()).options.dateProvider.now(), + effectiveScopes.options.dateProvider.now(), ) val parentSpan: ISpan? = if (forceScopes) scopes.getSpan() - else { - val currentScopes = Sentry.getCurrentScopes() - if (Platform.isAndroid()) currentScopes.transaction else currentScopes.span - } + else if (Platform.isAndroid()) effectiveScopes.transaction else effectiveScopes.span val spanOp = "http.client" - val spanDescription = "${request.method.value.toString()} ${request.url.buildString()}" + val rawUrl = request.url.buildString() + val urlDetails = UrlUtils.parse(rawUrl, effectiveScopes.options.dataCollectionResolver) + val spanDescription = "${request.method.value.toString()} ${urlDetails.urlOrFallback}" val span: ISpan? = parentSpan?.startChild(spanOp, spanDescription) if (span != null) { span.spanContext.origin = TRACE_ORIGIN @@ -120,13 +121,13 @@ public val SentryKtorClientPlugin: ClientPlugin = if ( !SpanUtils.isIgnored( - (if (forceScopes) scopes else Sentry.getCurrentScopes()).options.getIgnoredSpanOrigins(), + effectiveScopes.options.getIgnoredSpanOrigins(), TRACE_ORIGIN, ) ) { TracingUtils.traceIfAllowed( - if (forceScopes) scopes else Sentry.getCurrentScopes(), - request.url.buildString(), + effectiveScopes, + rawUrl, request.headers.getAll(BaggageHeader.BAGGAGE_HEADER), span, ) diff --git a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt index 8456f5658ee..38ffe609b2c 100644 --- a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt +++ b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt @@ -449,6 +449,16 @@ class SentryKtorClientPluginTest { assertTrue(httpClientSpan.isFinished) } + @Test + fun `span description excludes query parameters and fragment`(): Unit = runBlocking { + val sut = fixture.getSut() + sut.get(fixture.server.url("/hello?token=secret&page=1#results").toString()) + + val httpClientSpan = fixture.sentryTracer.children.first() + assertEquals("GET ${fixture.server.url("/hello")}", httpClientSpan.description) + assertNull(httpClientSpan.data[SpanDataConvention.HTTP_QUERY_KEY]) + } + @Test fun `finishes span setting throwable and status when request throws`(): Unit = runBlocking { val sut = fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY) From b5030a595f438e6abfb163551d8a1d07ac70593e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 24 Aug 2026 14:34:27 +0200 Subject: [PATCH 33/63] test(apollo): Cover response header filtering Verify Apollo 4 applies deny-list behavior to response headers for both supported execution implementations. Co-Authored-By: Claude --- ...yApollo4BuilderExtensionsClientErrorsTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt index 21bb0dc1b72..69cb5ed52b3 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -428,6 +428,23 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection filters response headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.response = KeyValueCollectionBehavior.denyList("content-length") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("[Filtered]", it.contexts.response!!.headers?.get("Content-Length")) + }, + any(), + ) + } + @Test fun `data collection can disable response headers`() { val sut = From 3b884f7a001d5447293f57d36bed973905064964 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Aug 2026 06:35:33 +0200 Subject: [PATCH 34/63] fix(opentelemetry): Exclude queries from span descriptions Parse the url.full fallback through the shared URL utility before using it as an HTTP span description. This removes query parameters and fragments while preserving route and target handling. Refs #5666 Co-Authored-By: Claude --- .../io/sentry/opentelemetry/SpanDescriptionExtractor.java | 7 +++---- .../src/test/kotlin/SpanDescriptionExtractorTest.kt | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index 3af3d8f96f0..d4dde49a4b6 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -10,6 +10,7 @@ import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes; import io.sentry.SentryOptions; import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.UrlUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -77,10 +78,8 @@ private OtelSpanInfo descriptionForHttpMethod( final @NotNull String op = opBuilder.toString(); final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); - if (urlFull != null) { - if (httpPath == null) { - httpPath = urlFull; - } + if (urlFull != null && httpPath == null) { + httpPath = UrlUtils.parse(urlFull).getUrl(); } final @Nullable String urlPath = attributes.get(UrlAttributes.URL_PATH); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt index a43afb849e6..04de1e97078 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -113,7 +113,7 @@ class SpanDescriptionExtractorTest { val info = whenExtractingSpanInfo() assertEquals("http.server", info.op) - assertEquals("GET https://sentry.io/some/path?q=1#top", info.description) + assertEquals("GET https://sentry.io/some/path", info.description) assertEquals(TransactionNameSource.URL, info.transactionNameSource) } From 3d2759045303a187c688f01fb757020d00cccf64 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 27 Aug 2026 14:50:21 +0200 Subject: [PATCH 35/63] fix(opentelemetry): Preserve completed request headers Do not apply Data Collection policies while converting completed OpenTelemetry attributes. Preserve the existing sendDefaultPii behavior because completed attributes may have been supplied manually by customers. Refs #5666 Co-Authored-By: Claude --- .../OpenTelemetryAttributesExtractor.java | 10 +---- .../OpenTelemetryAttributesExtractorTest.kt | 38 ------------------- 2 files changed, 1 insertion(+), 47 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java index 015e56d7949..87088ae2377 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java @@ -77,8 +77,6 @@ private void addRequestAttributesToScope( private static Map collectHeaders( final @NotNull Attributes attributes, final @NotNull SentryOptions options) { Map headers = new HashMap<>(); - final boolean isDataCollectionConfigured = - options.getDataCollectionResolver().isDataCollectionConfigured(); attributes.forEach( (key, value) -> { @@ -86,9 +84,7 @@ private static Map collectHeaders( if (attributeKeyAsString.startsWith(HTTP_REQUEST_HEADER_PREFIX)) { final @NotNull String headerName = StringUtils.removePrefix(attributeKeyAsString, HTTP_REQUEST_HEADER_PREFIX); - if (isDataCollectionConfigured - || options.isSendDefaultPii() - || !HttpUtils.containsSensitiveHeader(headerName)) { + if (options.isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { if (value instanceof List) { try { final @NotNull List headerValues = (List) value; @@ -106,10 +102,6 @@ private static Map collectHeaders( } } }); - if (isDataCollectionConfigured) { - return HttpUtils.filterHeaders( - headers, options.getDataCollectionResolver().getHttpRequestHeaders()); - } return headers; } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt index 01efc74164f..6d37240f0b2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -6,7 +6,6 @@ import io.opentelemetry.sdk.trace.data.SpanData import io.opentelemetry.semconv.HttpAttributes import io.opentelemetry.semconv.ServerAttributes import io.opentelemetry.semconv.UrlAttributes -import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.SentryOptions import io.sentry.protocol.Request @@ -324,43 +323,6 @@ class OpenTelemetryAttributesExtractorTest { thenHeaderIsNotPresentOnRequest("some-header") } - @Test - fun `data collection filters request header attributes`() { - fixture.options.dataCollection.httpHeaders.request = - KeyValueCollectionBehavior.denyList("customer") - givenAttributes( - mapOf( - HttpAttributes.HTTP_REQUEST_METHOD to "GET", - AttributeKey.stringArrayKey("http.request.header.content-type") to - listOf("application/json"), - AttributeKey.stringArrayKey("http.request.header.authorization") to listOf("Bearer token"), - AttributeKey.stringArrayKey("http.request.header.x-customer") to listOf("customer value"), - ) - ) - - whenExtractingAttributes() - - thenHeaderIsPresentOnRequest("content-type", "application/json") - thenHeaderIsPresentOnRequest("authorization", "[Filtered]") - thenHeaderIsPresentOnRequest("x-customer", "[Filtered]") - } - - @Test - fun `data collection can disable request header attributes`() { - fixture.options.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() - givenAttributes( - mapOf( - HttpAttributes.HTTP_REQUEST_METHOD to "GET", - AttributeKey.stringArrayKey("http.request.header.content-type") to - listOf("application/json"), - ) - ) - - whenExtractingAttributes() - - assertNull(fixture.scope.request!!.headers) - } - @Test fun `if there are no header attributes does not set headers on request`() { givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) From 90444709cc273c75f87e73b221ac932e26bcc354 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 27 Aug 2026 14:53:44 +0200 Subject: [PATCH 36/63] fix(opentelemetry): Preserve completed URL attributes Do not apply Data Collection policies while converting completed OpenTelemetry URL attributes. Preserve manually supplied values and leave attribute collection controls to OpenTelemetry. Refs #5666 Co-Authored-By: Claude --- .../OpenTelemetryAttributesExtractor.java | 6 ++-- .../OpenTelemetryAttributesExtractorTest.kt | 30 ------------------- 2 files changed, 2 insertions(+), 34 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java index ce79384fdef..87088ae2377 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java @@ -52,8 +52,7 @@ private void addRequestAttributesToScope( if (request.getUrl() == null) { final @Nullable String url = extractUrl(attributes, options); if (url != null) { - final @NotNull UrlUtils.UrlDetails urlDetails = - UrlUtils.parse(url, options.getDataCollectionResolver()); + final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(url); urlDetails.applyToRequest(request); } } @@ -61,8 +60,7 @@ private void addRequestAttributesToScope( if (request.getQueryString() == null) { final @Nullable String query = attributes.get(UrlAttributes.URL_QUERY); if (query != null) { - request.setQueryString( - UrlUtils.filterQueryParams(query, options.getDataCollectionResolver())); + request.setQueryString(query); } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt index c74dfa8a407..6d37240f0b2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -52,36 +52,6 @@ class OpenTelemetryAttributesExtractorTest { thenQueryIsSetTo("q=123456&b=X") } - @Test - fun `data collection filters URL query attributes`() { - fixture.options.dataCollection.setUserInfo(false) - givenAttributes( - mapOf( - HttpAttributes.HTTP_REQUEST_METHOD to "GET", - UrlAttributes.URL_QUERY to "name=value&token=secret", - ) - ) - - whenExtractingAttributes() - - thenQueryIsSetTo("name=value&token=[Filtered]") - } - - @Test - fun `data collection can disable URL query attributes`() { - fixture.options.dataCollection.urlQueryParams = KeyValueCollectionBehavior.off() - givenAttributes( - mapOf( - HttpAttributes.HTTP_REQUEST_METHOD to "GET", - UrlAttributes.URL_QUERY to "name=value", - ) - ) - - whenExtractingAttributes() - - assertNull(fixture.scope.request!!.queryString) - } - @Test fun `when there is an existing request on scope it is filled with more details`() { fixture.scope.request = Request().also { it.bodySize = 123L } From dc6cd575c82d9484c25a8a11116ec59f0368db70 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 27 Aug 2026 14:57:23 +0200 Subject: [PATCH 37/63] fix(opentelemetry): Normalize legacy HTTP target descriptions Exclude query parameters and fragments when deriving Sentry span descriptions from the legacy http.target attribute. Leave the completed OpenTelemetry attribute unchanged. Refs #5666 Co-Authored-By: Claude --- .../SpanDescriptionExtractor.java | 4 ++-- .../kotlin/SpanDescriptionExtractorTest.kt | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index d4dde49a4b6..af6d1b74e74 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -72,8 +72,8 @@ private OtelSpanInfo descriptionForHttpMethod( final @Nullable String httpTarget = attributes.get(HttpIncubatingAttributes.HTTP_TARGET); final @Nullable String httpRoute = attributes.get(HttpAttributes.HTTP_ROUTE); @Nullable String httpPath = httpRoute; - if (httpPath == null) { - httpPath = httpTarget; + if (httpPath == null && httpTarget != null) { + httpPath = UrlUtils.parse(httpTarget).getUrl(); } final @NotNull String op = opBuilder.toString(); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt index 04de1e97078..5100ff715bb 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -132,7 +132,7 @@ class SpanDescriptionExtractorTest { } @Test - fun `uses HTTP_TARGET for description`() { + fun `uses HTTP_ROUTE over HTTP_TARGET for description`() { givenSpanKind(SpanKind.SERVER) givenAttributes( mapOf( @@ -150,6 +150,23 @@ class SpanDescriptionExtractorTest { assertEquals(TransactionNameSource.ROUTE, info.transactionNameSource) } + @Test + fun `removes query and fragment from HTTP_TARGET description`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + HttpIncubatingAttributes.HTTP_TARGET to "/checkout?page=1&token=secret#details", + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertEquals("GET /checkout", info.description) + assertEquals(TransactionNameSource.URL, info.transactionNameSource) + } + @Test fun `uses span name as description fallback`() { givenSpanKind(SpanKind.SERVER) From f1f23e18c06c602f3bd0f751a1b4dbdce1324c95 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 28 Aug 2026 09:40:34 +0200 Subject: [PATCH 38/63] fix(database): Preserve query descriptions Keep sanitized or parameterized query text independent of databaseQueryData. The option only controls bound parameters, write payloads, and result data, which the current JDBC and SQLite integrations do not collect. Remove the unused legacy resolver path and its policy-specific tests. Co-Authored-By: Claude --- .../sentry/android/sqlite/OpenHelperSpans.kt | 13 ++--------- .../main/java/io/sentry/sqlite/DriverSpans.kt | 5 +--- .../android/sqlite/OpenHelperSpansTest.kt | 22 ------------------ .../java/io/sentry/sqlite/DriverSpansTest.kt | 22 ------------------ .../sentry/jdbc/SentryJdbcEventListener.java | 6 +---- .../jdbc/SentryJdbcEventListenerTest.kt | 23 ------------------- sentry/api/sentry.api | 1 - .../io/sentry/DataCollectionResolver.java | 4 ---- .../io/sentry/DataCollectionResolverTest.kt | 11 --------- 9 files changed, 4 insertions(+), 103 deletions(-) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt index 4fe75ef4d28..059eb1bb1b5 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt @@ -6,7 +6,6 @@ import io.sentry.IScopes import io.sentry.ISpan import io.sentry.Instrumenter import io.sentry.ScopesAdapter -import io.sentry.SentryDate import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryStackTraceFactory import io.sentry.SpanDataConvention @@ -47,12 +46,12 @@ internal class OpenHelperSpans( if (result is CrossProcessCursor) { return SentryCrossProcessCursor(result, this, sql) as T } - span = startSpan(sql, startTimestamp) + span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) span?.spanContext?.origin = TRACE_ORIGIN span?.status = SpanStatus.OK result } catch (e: Throwable) { - span = startSpan(sql, startTimestamp) + span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) span?.spanContext?.origin = TRACE_ORIGIN span?.status = SpanStatus.INTERNAL_ERROR span?.throwable = e @@ -77,12 +76,4 @@ internal class OpenHelperSpans( } } } - - private fun startSpan(sql: String, startTimestamp: SentryDate): ISpan? = - scopes.span?.startChild( - "db.sql.query", - sql.takeIf { scopes.options.dataCollectionResolver.isDatabaseQueryDataWithLegacyAlways }, - startTimestamp, - Instrumenter.SENTRY, - ) } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt index fe2b15a33bb..b3c0eb7c713 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt @@ -50,10 +50,7 @@ internal class DriverSpans(private val scopes: IScopes, private val dbMetadata: val startTimestamp = SentryLongDate(startTimestampNanos) val endTimestamp = SentryLongDate(startTimestampNanos + durationNanos) - val description = sql.takeIf { - scopes.options.dataCollectionResolver.isDatabaseQueryDataWithLegacyAlways - } - parent.startChild("db.sql.query", description, startTimestamp, Instrumenter.SENTRY).apply { + parent.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY).apply { spanContext.origin = SQLITE_TRACE_ORIGIN throwable?.let { this.throwable = it } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt index 8b442c59ee5..0552094838e 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt @@ -66,28 +66,6 @@ class OpenHelperSpansTest { assertTrue(span.isFinished) } - @Test - fun `performSql omits description when database query data is disabled`() { - val sut = fixture.getSut() - fixture.options.dataCollection.setDatabaseQueryData(false) - - sut.performSql("SELECT secret FROM users") {} - - val span = fixture.sentryTracer.children.first() - assertNull(span.description) - assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) - } - - @Test - fun `performSql keeps description in legacy mode`() { - val sut = fixture.getSut() - fixture.options.isSendDefaultPii = false - - sut.performSql("SELECT secret FROM users") {} - - assertEquals("SELECT secret FROM users", fixture.sentryTracer.children.first().description) - } - @Test fun `performSql does not create a span if no span is running`() { val sut = fixture.getSut(isSpanActive = false) diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt index 2265d10aa75..319fc20d7ce 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt @@ -129,28 +129,6 @@ class DriverSpansTest { assertTrue(span.isFinished) } - @Test - fun `record method omits description when database query data is disabled`() { - val sut = fixture.getSut() - fixture.options.dataCollection.setDatabaseQueryData(false) - - sut.record("SELECT secret FROM users", sut.startTimestamp(), 1_000_000, SpanStatus.OK) - - val span = fixture.sentryTracer.children.first() - assertNull(span.description) - assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) - } - - @Test - fun `record method keeps description in legacy mode`() { - val sut = fixture.getSut() - fixture.options.isSendDefaultPii = false - - sut.record("SELECT secret FROM users", sut.startTimestamp(), 1_000_000, SpanStatus.OK) - - assertEquals("SELECT secret FROM users", fixture.sentryTracer.children.first().description) - } - @Test fun `record method sets finishDate equal to startDate + durationNanos`() { val sut = fixture.getSut() diff --git a/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java b/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java index 59e50efae26..4206de18002 100644 --- a/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java +++ b/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java @@ -47,11 +47,7 @@ public SentryJdbcEventListener() { @Override public void onBeforeAnyExecute(final @NotNull StatementInformation statementInformation) { - final @Nullable String description = - scopes.getOptions().getDataCollectionResolver().isDatabaseQueryDataWithLegacyAlways() - ? statementInformation.getSql() - : null; - startSpan(CURRENT_QUERY_SPAN, "db.query", description); + startSpan(CURRENT_QUERY_SPAN, "db.query", statementInformation.getSql()); } @Override diff --git a/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt b/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt index 436bc4abf62..22ee97e5d47 100644 --- a/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt +++ b/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt @@ -90,29 +90,6 @@ class SentryJdbcEventListenerTest { assertEquals("INSERT INTO foo VALUES (2)", fixture.tx.children[1].description) } - @Test - fun `omits query description when database query data is disabled`() { - val sut = fixture.getSut() - fixture.options.dataCollection.setDatabaseQueryData(false) - - sut.connection.use { it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() } - - assertEquals(1, fixture.tx.children.size) - assertEquals(null, fixture.tx.children.first().description) - assertEquals("hsqldb", fixture.tx.children.first().data[DB_SYSTEM_KEY]) - assertEquals("testdb", fixture.tx.children.first().data[DB_NAME_KEY]) - } - - @Test - fun `legacy mode keeps query description when sendDefaultPii is false`() { - val sut = fixture.getSut() - fixture.options.isSendDefaultPii = false - - sut.connection.use { it.prepareStatement("INSERT INTO foo VALUES (1)").executeUpdate() } - - assertEquals("INSERT INTO foo VALUES (1)", fixture.tx.children.first().description) - } - @Test fun `creates spans for calls resulting in error`() { val sut = fixture.getSut(existingRow = 1) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index cd99b769e03..1885b42e6f0 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -422,7 +422,6 @@ public final class io/sentry/DataCollectionResolver { public fun getUrlQueryParams ()Lio/sentry/KeyValueCollectionBehavior; public fun isDataCollectionConfigured ()Z public fun isDatabaseQueryData ()Z - public fun isDatabaseQueryDataWithLegacyAlways ()Z public fun isGraphqlDocument ()Z public fun isGraphqlDocumentWithLegacyAlways ()Z public fun isGraphqlDocumentWithLegacyBodyGate ()Z diff --git a/sentry/src/main/java/io/sentry/DataCollectionResolver.java b/sentry/src/main/java/io/sentry/DataCollectionResolver.java index e3c05a91a5a..32f642cd871 100644 --- a/sentry/src/main/java/io/sentry/DataCollectionResolver.java +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -31,10 +31,6 @@ public boolean isDatabaseQueryData() { return explicitOrSendDefaultPii(options.getDataCollection().getDatabaseQueryData(), true); } - public boolean isDatabaseQueryDataWithLegacyAlways() { - return explicitOrDefault(options.getDataCollection().getDatabaseQueryData(), true, true); - } - public boolean isGraphqlDocument() { return explicitOrSendDefaultPii(options.getDataCollection().getGraphql().getDocument(), true); } diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt index 4c755db6296..3080ea86226 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -78,17 +78,6 @@ class DataCollectionResolverTest { assertThat(options.dataCollectionResolver.isDatabaseQueryData).isFalse() } - @Test - fun `database query data legacy always variant preserves collection when namespace is absent`() { - val options = SentryOptions().apply { isSendDefaultPii = false } - - assertThat(options.dataCollectionResolver.isDatabaseQueryDataWithLegacyAlways).isTrue() - - options.dataCollection.setDatabaseQueryData(false) - - assertThat(options.dataCollectionResolver.isDatabaseQueryDataWithLegacyAlways).isFalse() - } - @Test fun `GraphQL document falls back to sendDefaultPii and override takes precedence`() { val options = SentryOptions().apply { isSendDefaultPii = true } From 425d776d34caea89d7dc7bedad3c830032c08bbb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 28 Aug 2026 14:48:15 +0200 Subject: [PATCH 39/63] fix(replay): Keep Replay independent from Data Collection Restore Session Replay network privacy settings as the only controls for Replay data. Data Collection and sendDefaultPii do not affect Replay, which avoids changing behavior for existing Replay users. Add coverage that restrictive Data Collection settings leave explicitly enabled Replay network details unchanged. Refs #5666 Co-Authored-By: Claude --- .../android/core/ManifestMetadataReader.java | 80 ++++---- .../core/ManifestMetadataReaderTest.kt | 72 ++++--- .../sentry/okhttp/SentryOkHttpInterceptor.kt | 16 +- .../okhttp/SentryOkHttpInterceptorTest.kt | 32 +++ 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 +++++----- 11 files changed, 282 insertions(+), 521 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 ebcfb5de5ba..7a9cd8a4d13 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,6 +10,7 @@ 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; @@ -638,20 +639,47 @@ static void applyMetadata( options .getSessionReplay() .setNetworkCaptureBodies( - readBoolNullable( + readBool( metadata, logger, REPLAYS_NETWORK_CAPTURE_BODIES, - options.getSessionReplay().getNetworkCaptureBodies())); + 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() - .setNetworkRequestHeaders( - readTrimmedList(metadata, logger, REPLAYS_NETWORK_REQUEST_HEADERS)); - options - .getSessionReplay() - .setNetworkResponseHeaders( - readTrimmedList(metadata, logger, REPLAYS_NETWORK_RESPONSE_HEADERS)); + 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.setIgnoredErrors(readList(metadata, logger, IGNORED_ERRORS)); @@ -755,21 +783,6 @@ 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, @@ -801,23 +814,6 @@ 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 1760aaee6ab..d0dbd1deb50 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,7 +6,6 @@ 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 @@ -2348,11 +2347,11 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(false, fixture.options.sessionReplay.networkCaptureBodies) + assertFalse(fixture.options.sessionReplay.isNetworkCaptureBodies) } @Test - fun `applyMetadata keeps networkCaptureBodies unset when not present`() { + fun `applyMetadata keeps default networkCaptureBodies as true when not present`() { // Arrange val context = fixture.getContext() @@ -2360,11 +2359,11 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.sessionReplay.networkCaptureBodies) + assertTrue(fixture.options.sessionReplay.isNetworkCaptureBodies) } @Test - fun `applyMetadata keeps networkRequestHeaderBehavior unset when not present`() { + fun `applyMetadata keeps the default networkRequestHeaders`() { // Arrange val context = fixture.getContext() @@ -2372,7 +2371,12 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.sessionReplay.networkRequestHeaderBehavior) + val headers = fixture.options.sessionReplay.networkRequestHeaders + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + // Should have exactly the default headers + assertEquals(defaultHeaders.size, headers.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(headers.contains(defaultHeader)) } } @Test @@ -2386,16 +2390,20 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - 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")) + val allHeaders = fixture.options.sessionReplay.networkRequestHeaders + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + // Should include default headers + additional headers + defaultHeaders.forEach { defaultHeader -> + assertTrue(allHeaders.contains(defaultHeader)) // default + } + assertTrue(allHeaders.contains("Authorization")) // additional + assertTrue(allHeaders.contains("X-Custom-Header")) // additional + assertTrue(allHeaders.contains("X-Request-Id")) // additional } @Test - fun `applyMetadata keeps networkResponseHeaderBehavior unset when not present`() { + fun `applyMetadata keeps the default networkResponseHeaders`() { // Arrange val context = fixture.getContext() @@ -2403,7 +2411,12 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.sessionReplay.networkResponseHeaderBehavior) + val headers = fixture.options.sessionReplay.networkResponseHeaders + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + // Should have exactly the default headers + assertEquals(defaultHeaders.size, headers.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(headers.contains(defaultHeader)) } } @Test @@ -2418,12 +2431,13 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - 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")) + val allHeaders = fixture.options.sessionReplay.networkResponseHeaders + // Should include default headers + additional headers + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + defaultHeaders.forEach { defaultHeader -> assertTrue(allHeaders.contains(defaultHeader)) } + assertTrue(allHeaders.contains("X-Response-Time")) // additional + assertTrue(allHeaders.contains("X-Cache-Status")) // additional + assertTrue(allHeaders.contains("X-Server-Id")) // additional } @Test @@ -2458,8 +2472,16 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.sessionReplay.networkRequestHeaderBehavior) - assertNull(fixture.options.sessionReplay.networkResponseHeaderBehavior) + // Should still have default headers even with empty string + val defaultHeaders = SentryReplayOptions.getNetworkDetailsDefaultHeaders() + + val requestHeaders = fixture.options.sessionReplay.networkRequestHeaders + assertEquals(defaultHeaders.size, requestHeaders.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(requestHeaders.contains(defaultHeader)) } + + val responseHeaders = fixture.options.sessionReplay.networkResponseHeaders + assertEquals(defaultHeaders.size, responseHeaders.size) + defaultHeaders.forEach { defaultHeader -> assertTrue(responseHeaders.contains(defaultHeader)) } } @Test @@ -2496,9 +2518,9 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - val behavior = fixture.options.sessionReplay.networkRequestHeaderBehavior - assertTrue(behavior!!.terms.contains("Authorization")) - assertTrue(behavior.terms.contains("X-Custom-Header")) + val headers = fixture.options.sessionReplay.networkRequestHeaders + assertTrue(headers.contains("Authorization")) + assertTrue(headers.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 1928f30ff88..ed704966610 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -146,9 +146,7 @@ public open class SentryOkHttpInterceptor( NetworkDetailCaptureUtils.createRequest( request, requestContentLength, - scopes.options.sessionReplay.isNetworkRequestBodyCaptureEnabled( - scopes.options.dataCollectionResolver - ), + scopes.options.sessionReplay.isNetworkCaptureBodies, { req -> req.body?.let { originalBody -> val buffer = okio.Buffer() @@ -163,9 +161,7 @@ public open class SentryOkHttpInterceptor( safeExtractRequestBody(bodyBytes, originalBody.contentType(), scopes.options.logger) } }, - scopes.options.sessionReplay.resolveNetworkRequestHeaders( - scopes.options.dataCollectionResolver - ), + scopes.options.sessionReplay.networkRequestHeaders, { req: Request -> req.headers.toMap() }, ) ) @@ -209,13 +205,9 @@ public open class SentryOkHttpInterceptor( NetworkDetailCaptureUtils.createResponse( it, it.body?.contentLength(), - scopes.options.sessionReplay.isNetworkResponseBodyCaptureEnabled( - scopes.options.dataCollectionResolver - ), + scopes.options.sessionReplay.isNetworkCaptureBodies, { resp: Response -> resp.extractResponseBody(scopes.options.logger) }, - scopes.options.sessionReplay.resolveNetworkResponseHeaders( - scopes.options.dataCollectionResolver - ), + scopes.options.sessionReplay.networkResponseHeaders, { resp: Response -> resp.headers.toMap() }, ), ) diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index 7b49105dc13..750406f3d22 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -8,6 +8,7 @@ import io.sentry.Hint import io.sentry.HttpStatusCodeRange import io.sentry.IScope import io.sentry.IScopes +import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.Sentry @@ -22,6 +23,7 @@ import io.sentry.TypeCheckHint import io.sentry.W3CTraceparentHeader import io.sentry.exception.SentryHttpClientException import io.sentry.mockServerRequestTimeoutMillis +import io.sentry.util.network.NetworkRequestData import java.io.IOException import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -45,6 +47,7 @@ import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock @@ -324,6 +327,35 @@ class SentryOkHttpInterceptorTest { ) } + @Test + fun `data collection settings do not affect Session Replay network details`() { + val sut = + fixture.getSut( + optionsConfiguration = { + it.dataCollection.httpBodies = emptySet() + it.dataCollection.httpHeaders.request = KeyValueCollectionBehavior.off() + it.dataCollection.httpHeaders.response = KeyValueCollectionBehavior.off() + it.sessionReplay.setNetworkDetailAllowUrls(listOf(".*")) + } + ) + + val request = postRequest().newBuilder().addHeader("Accept", "application/json").build() + sut.newCall(request).execute() + + val hint = argumentCaptor() + verify(fixture.scopes).addBreadcrumb(any(), hint.capture()) + val networkDetails = + assertNotNull( + hint.firstValue.getAs( + TypeCheckHint.SENTRY_REPLAY_NETWORK_DETAILS, + NetworkRequestData::class.java, + ) + ) + assertEquals("request-body", networkDetails.request?.body?.body) + assertEquals("application/json", networkDetails.request?.headers?.get("Accept")) + assertNotNull(networkDetails.response) + } + @SuppressWarnings("SwallowedException") @Test fun `adds breadcrumb when http calls results in exception`() { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0c273bc3517..47d2db96f9e 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4170,13 +4170,10 @@ 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; @@ -4188,26 +4185,19 @@ 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 @@ -8122,9 +8112,7 @@ 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 8d53c37d0d9..d1da6510cdb 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -197,10 +197,11 @@ public enum SentryReplayQuality { private @NotNull List networkDetailDenyUrls = Collections.emptyList(); /** - * 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. + * 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)}. */ - private @Nullable Boolean networkCaptureBodies; + private boolean networkCaptureBodies = true; /** Default headers that are always captured for URLs defined in networkDetailAllowUrls. */ private static final @NotNull List DEFAULT_HEADERS = @@ -216,11 +217,17 @@ public enum SentryReplayQuality { return DEFAULT_HEADERS; } - /** Explicit request-header collection behavior, or null to inherit. */ - private @Nullable KeyValueCollectionBehavior networkRequestHeaderBehavior; + /** + * 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 response-header collection behavior, or null to inherit. */ - private @Nullable KeyValueCollectionBehavior networkResponseHeaderBehavior; + /** + * 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; /** * A callback that is called before the error sample rate is checked for session replay. Can be @@ -475,184 +482,62 @@ 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 the explicit value, or the legacy default of {@code true} when unset - * @deprecated Use {@link #getNetworkCaptureBodies()} to distinguish an explicit value from - * inheritance. + * @return true if network capture bodies is enabled, false otherwise */ - @Deprecated public boolean isNetworkCaptureBodies() { - 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; + return networkCaptureBodies; } /** * Sets whether to capture request and response bodies for URLs defined in networkDetailAllowUrls. + * + * @param networkCaptureBodies true to enable network capture bodies, false otherwise */ public void setNetworkCaptureBodies(final boolean networkCaptureBodies) { this.networkCaptureBodies = networkCaptureBodies; } /** - * Gets 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. + * Gets all request headers to capture for URLs defined in networkDetailAllowUrls. This includes + * both the default headers (Content-Type, Content-Length, Accept) and any additional headers. * - * @return the 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. + * @return an unmodifiable list of the request headers to extract */ - @Deprecated public @NotNull List getNetworkRequestHeaders() { - return getLegacyHeaderList(networkRequestHeaderBehavior); + return networkRequestHeaders; } /** * Sets request headers to capture for URLs defined in networkDetailAllowUrls. The default headers - * (Content-Type, Content-Length, Accept) are always included automatically. Pass {@code null} to - * inherit from Data Collection. + * (Content-Type, Content-Length, Accept) are always included automatically. * - * @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. + * @param networkRequestHeaders additional network request headers list */ - 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; + public void setNetworkRequestHeaders(final @NotNull List networkRequestHeaders) { + this.networkRequestHeaders = mergeHeaders(DEFAULT_HEADERS, networkRequestHeaders); } /** - * Gets response header allow-list terms for URLs defined in networkDetailAllowUrls. + * Gets all response headers to capture for URLs defined in networkDetailAllowUrls. This includes + * both the default headers (Content-Type, Content-Length, Accept) and any additional headers. * - * @return the 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. + * @return an unmodifiable list of the response headers to extract */ - @Deprecated public @NotNull List getNetworkResponseHeaders() { - return getLegacyHeaderList(networkResponseHeaderBehavior); + return networkResponseHeaders; } /** * Sets response headers to capture for URLs defined in networkDetailAllowUrls. The default - * headers (Content-Type, Content-Length, Accept) are always included automatically. Pass {@code - * null} to inherit from Data Collection. + * headers (Content-Type, Content-Length, Accept) are always included automatically. * - * @deprecated Use {@link #setNetworkResponseHeaderBehavior(KeyValueCollectionBehavior)}. + * @param networkResponseHeaders the additional network response headers list */ - @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])); + public void setNetworkResponseHeaders(final @NotNull List networkResponseHeaders) { + this.networkResponseHeaders = mergeHeaders(DEFAULT_HEADERS, networkResponseHeaders); } /** diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java index b4bccb009e8..5305e59a321 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -4,7 +4,6 @@ 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; @@ -13,7 +12,6 @@ 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; @@ -68,29 +66,9 @@ public RRWebOptionsEvent(final @NotNull SentryOptions options) { if (!replayOptions.getNetworkDetailAllowUrls().isEmpty()) { optionsPayload.put("networkDetailAllowUrls", replayOptions.getNetworkDetailAllowUrls()); - 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())); + optionsPayload.put("networkRequestHeaders", replayOptions.getNetworkRequestHeaders()); + optionsPayload.put("networkResponseHeaders", replayOptions.getNetworkResponseHeaders()); + optionsPayload.put("networkCaptureBodies", replayOptions.isNetworkCaptureBodies()); 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 40905b99975..f5134693e00 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -1,10 +1,11 @@ package io.sentry.util.network; -import io.sentry.KeyValueCollectionBehavior; -import io.sentry.util.HttpUtils; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; @@ -45,11 +46,7 @@ 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, @@ -57,26 +54,6 @@ 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, @@ -87,14 +64,6 @@ 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, @@ -102,22 +71,6 @@ 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, @@ -169,11 +122,28 @@ private static boolean shouldCaptureUrl( @VisibleForTesting static @NotNull Map getCaptureHeaders( - @Nullable final Map allHeaders, - @NotNull final KeyValueCollectionBehavior behavior) { - return allHeaders == null - ? new LinkedHashMap() - : HttpUtils.filterHeaders(allHeaders, behavior); + @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; } private static @NotNull ReplayNetworkRequestOrResponse createRequestOrResponseInternal( @@ -181,7 +151,7 @@ private static boolean shouldCaptureUrl( @Nullable final Long bodySize, final boolean networkCaptureBodies, @NotNull final NetworkBodyExtractor bodyExtractor, - @NotNull final KeyValueCollectionBehavior headerBehavior, + @NotNull final List allowedHeaders, @NotNull final NetworkHeaderExtractor headerExtractor) { NetworkBody body = null; @@ -197,7 +167,7 @@ private static boolean shouldCaptureUrl( } Map headers = - getCaptureHeaders(headerExtractor.extract(httpObject), headerBehavior); + getCaptureHeaders(headerExtractor.extract(httpObject), allowedHeaders); 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 ce16d0fc8c5..114ef702e43 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -4,7 +4,6 @@ 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 { @@ -69,168 +68,71 @@ class SentryReplayOptionsTest { // https://docs.sentry.io/platforms/javascript/session-replay/configuration/#network-details @Test - fun `network detail collection overrides default to null`() { + fun `getNetworkRequestHeaders returns default headers by default`() { val options = SentryReplayOptions(false, null) + assertEquals( + SentryReplayOptions.getNetworkDetailsDefaultHeaders().size, + options.networkRequestHeaders.size, + ) - assertNull(options.networkCaptureBodies) - assertNull(options.networkRequestHeaderBehavior) - assertNull(options.networkResponseHeaderBehavior) - } - - @Test - 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) + val headers = options.networkRequestHeaders + SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> + assertEquals(true, headers.contains(defaultHeader)) + } } - @Suppress("DEPRECATION") @Test - fun `legacy network getters preserve defaults when overrides are null`() { + fun `getNetworkResponseHeaders returns default headers by default`() { val options = SentryReplayOptions(false, null) - - assertTrue(options.isNetworkCaptureBodies) assertEquals( - SentryReplayOptions.getNetworkDetailsDefaultHeaders(), - options.networkRequestHeaders, - ) - assertEquals( - SentryReplayOptions.getNetworkDetailsDefaultHeaders(), - options.networkResponseHeaders, + SentryReplayOptions.getNetworkDetailsDefaultHeaders().size, + options.networkResponseHeaders.size, ) + + val headers = options.networkResponseHeaders + SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> + assertEquals(true, headers.contains(defaultHeader)) + } } - @Suppress("DEPRECATION") @Test - fun `legacy header setters create allow list overrides including default headers`() { + fun `setNetworkRequestHeaders adds to default headers`() { val options = SentryReplayOptions(false, null) + val additionalHeaders = listOf("X-Custom-Header", "X-Another-Header") - options.setNetworkRequestHeaders(listOf("X-Custom-Header")) - options.setNetworkResponseHeaders(listOf("X-Response-Header")) + options.setNetworkRequestHeaders(additionalHeaders) assertEquals( - KeyValueCollectionBehavior.Mode.ALLOW_LIST, - options.networkRequestHeaderBehavior?.mode, + SentryReplayOptions.getNetworkDetailsDefaultHeaders().size + additionalHeaders.size, + options.networkRequestHeaders.size, ) - 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")) - } - @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) + 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 getters return empty lists for non allow list behavior`() { + fun `setNetworkResponseHeaders adds to default headers`() { val options = SentryReplayOptions(false, null) + val additionalHeaders = listOf("X-Response-Header", "X-Debug-Header") - options.networkRequestHeaderBehavior = KeyValueCollectionBehavior.denyList("x-debug") - options.networkResponseHeaderBehavior = KeyValueCollectionBehavior.off() + options.setNetworkResponseHeaders(additionalHeaders) - 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( - defaultHeaders, - replay.resolveNetworkResponseHeaders(options.dataCollectionResolver), + SentryReplayOptions.getNetworkDetailsDefaultHeaders().size + additionalHeaders.size, + options.networkResponseHeaders.size, ) - } - @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), - ) + val headers = options.networkResponseHeaders + SentryReplayOptions.getNetworkDetailsDefaultHeaders().forEach { defaultHeader -> + assertTrue(headers.contains(defaultHeader)) + } + assertTrue(headers.contains("X-Response-Header")) + assertTrue(headers.contains("X-Debug-Header")) } // 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 e023f37fe89..32dbd9a7d47 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebOptionsEventSerializationTest.kt @@ -1,8 +1,6 @@ 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 @@ -106,22 +104,6 @@ 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 6df55961bc8..25b142af7e9 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt @@ -1,7 +1,6 @@ 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 @@ -24,7 +23,7 @@ class NetworkDetailCaptureUtilsTest { { bytes -> NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) }, - KeyValueCollectionBehavior.off(), + emptyList(), { emptyMap() }, ) @@ -44,7 +43,7 @@ class NetworkDetailCaptureUtilsTest { { bytes -> NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) }, - KeyValueCollectionBehavior.off(), + emptyList(), { emptyMap() }, ) @@ -59,7 +58,7 @@ class NetworkDetailCaptureUtilsTest { null, false, { null }, - KeyValueCollectionBehavior.off(), + emptyList(), { emptyMap() }, ) @@ -67,7 +66,8 @@ class NetworkDetailCaptureUtilsTest { } @Test - fun `getCaptureHeaders matches allow list case-insensitively and filters sensitive values`() { + fun `getCaptureHeaders should match headers case-insensitively`() { + // Setup: allHeaders with mixed case keys val allHeaders = LinkedHashMap().apply { put("Content-Type", "application/json") @@ -75,21 +75,22 @@ class NetworkDetailCaptureUtilsTest { put("X-Custom-Header", "custom-value") put("accept", "application/json") } - val behavior = - KeyValueCollectionBehavior.allowList( - "content-type", - "AUTHORIZATION", - "x-custom-header", - "ACCEPT", - ) - val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, behavior) + // Test: allowedHeaders with different casing + val allowedHeaders = listOf("content-type", "AUTHORIZATION", "x-custom-header", "ACCEPT") + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + // All headers should be matched despite case differences assertEquals(4, result.size) + + // Original casing should be preserved in output assertEquals("application/json", result["Content-Type"]) - assertEquals("[Filtered]", result["Authorization"]) + assertEquals("Bearer token123", result["Authorization"]) assertEquals("custom-value", result["X-Custom-Header"]) assertEquals("application/json", result["accept"]) + + // Verify keys maintain original casing from allHeaders assertTrue(result.containsKey("Content-Type")) assertTrue(result.containsKey("Authorization")) assertTrue(result.containsKey("X-Custom-Header")) @@ -97,52 +98,65 @@ class NetworkDetailCaptureUtilsTest { } @Test - fun `getCaptureHeaders handles null allHeaders`() { - val result = - NetworkDetailCaptureUtils.getCaptureHeaders( - null, - KeyValueCollectionBehavior.allowList("content-type"), - ) + fun `getCaptureHeaders should handle null allHeaders`() { + val allowedHeaders = listOf("content-type") + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(null, allowedHeaders) assertTrue(result.isEmpty()) } @Test - fun `getCaptureHeaders filters every value for empty allow list`() { - val result = - NetworkDetailCaptureUtils.getCaptureHeaders( - mapOf("Content-Type" to "application/json"), - KeyValueCollectionBehavior.allowList(), - ) + fun `getCaptureHeaders should handle empty allowedHeaders`() { + val allHeaders = mapOf("Content-Type" to "application/json") + val allowedHeaders = emptyList() + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) - assertEquals(mapOf("Content-Type" to "[Filtered]"), result) + assertTrue(result.isEmpty()) } @Test - 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"), + fun `getCaptureHeaders should only capture allowed headers`() { + val allHeaders = + mapOf( + "Content-Type" to "application/json", + "Authorization" to "Bearer token123", + "X-Unwanted-Header" to "should-not-appear", ) + val allowedHeaders = listOf("content-type", "authorization") + + val result = NetworkDetailCaptureUtils.getCaptureHeaders(allHeaders, allowedHeaders) + + assertEquals(2, result.size) assertEquals("application/json", result["Content-Type"]) - assertEquals("[Filtered]", result["X-Debug"]) - assertEquals("123", result["X-Request-Id"]) + assertEquals("Bearer token123", result["Authorization"]) + + // Unwanted header should not be present + assertTrue(!result.containsKey("X-Unwanted-Header")) } @Test - fun `getCaptureHeaders applies off mode`() { - val result = - NetworkDetailCaptureUtils.getCaptureHeaders( - mapOf("Content-Type" to "application/json"), - KeyValueCollectionBehavior.off(), + 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", ) - assertTrue(result.isEmpty()) + // 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")) } } From 3a6fa4f7cd21158c31c37dd0039a7b5c6addd949 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 31 Aug 2026 06:21:47 +0200 Subject: [PATCH 40/63] test(graphql): Cover GraphqlUtils request body filtering Add focused coverage for parsing a single GraphQL request object and independently removing document and variable content while preserving operation metadata and allowed fields. Refs #5666 Co-Authored-By: Claude --- .../java/io/sentry/util/GraphqlUtilsTest.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt diff --git a/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt new file mode 100644 index 00000000000..06f385320cf --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt @@ -0,0 +1,42 @@ +package io.sentry.util + +import com.google.common.truth.Truth.assertThat +import io.sentry.JsonObjectReader +import io.sentry.SentryOptions +import java.io.StringReader +import kotlin.test.Test + +class GraphqlUtilsTest { + @Test + fun `filters document from a GraphQL request body`() { + val options = SentryOptions().also { it.dataCollection.graphql.setDocument(false) } + + val result = GraphqlUtils.filterRequestBody(REQUEST_BODY, options) + + JsonObjectReader(StringReader(result)).use { reader -> + @Suppress("UNCHECKED_CAST") val body = reader.nextObjectOrNull() as Map + assertThat(body).containsEntry("operationName", "GetUser") + assertThat(body).containsEntry("variables", mapOf("id" to "123")) + assertThat(body).doesNotContainKey("query") + } + } + + @Test + fun `filters variables from a GraphQL request body`() { + val options = SentryOptions().also { it.dataCollection.graphql.setVariables(false) } + + val result = GraphqlUtils.filterRequestBody(REQUEST_BODY, options) + + JsonObjectReader(StringReader(result)).use { reader -> + @Suppress("UNCHECKED_CAST") val body = reader.nextObjectOrNull() as Map + assertThat(body).containsEntry("operationName", "GetUser") + assertThat(body).containsEntry("query", "query { viewer { name } }") + assertThat(body).doesNotContainKey("variables") + } + } + + private companion object { + const val REQUEST_BODY = + """{"operationName":"GetUser","variables":{"id":"123"},"query":"query { viewer { name } }"}""" + } +} From d0020cf11f312c8f9255573207e1d03cfa235d45 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 31 Aug 2026 09:11:45 +0200 Subject: [PATCH 41/63] fix(graphql): Filter batched GraphQL request bodies Apply document and variable collection policies to every operation in a batched GraphQL request. Fail closed when a batch contains non-object entries instead of attaching partially filtered content. Refs #5666 Co-Authored-By: Claude --- .../java/io/sentry/util/GraphqlUtils.java | 49 ++++++++++++++----- .../java/io/sentry/util/GraphqlUtilsTest.kt | 40 +++++++++++++++ 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/sentry/src/main/java/io/sentry/util/GraphqlUtils.java b/sentry/src/main/java/io/sentry/util/GraphqlUtils.java index 30c164e3a00..06893e6f5a8 100644 --- a/sentry/src/main/java/io/sentry/util/GraphqlUtils.java +++ b/sentry/src/main/java/io/sentry/util/GraphqlUtils.java @@ -5,7 +5,10 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import java.io.StringReader; +import java.io.StringWriter; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -31,23 +34,45 @@ private GraphqlUtils() {} try (JsonObjectReader reader = new JsonObjectReader(new StringReader(body))) { final @Nullable Object value = reader.nextObjectOrNull(); - if (!(value instanceof Map)) { + final @NotNull Object filtered; + if (value instanceof Map) { + @SuppressWarnings("unchecked") + final @NotNull Map requestBody = (Map) value; + filtered = filterRequest(requestBody, includeDocument, includeVariables); + } else if (value instanceof List) { + final @NotNull List> filteredBatch = new ArrayList<>(); + for (final @Nullable Object item : (List) value) { + if (!(item instanceof Map)) { + return null; + } + @SuppressWarnings("unchecked") + final @NotNull Map requestBody = (Map) item; + filteredBatch.add(filterRequest(requestBody, includeDocument, includeVariables)); + } + filtered = filteredBatch; + } else { return null; } - - @SuppressWarnings("unchecked") - final @NotNull Map requestBody = (Map) value; - final @NotNull Map filtered = new LinkedHashMap<>(requestBody); - if (!includeDocument) { - filtered.remove("query"); - } - if (!includeVariables) { - filtered.remove("variables"); - } - return options.getSerializer().serialize(filtered); + final @NotNull StringWriter writer = new StringWriter(); + options.getSerializer().serialize(filtered, writer); + return writer.toString(); } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, "Failed to filter GraphQL request body.", e); return null; } } + + private static @NotNull Map filterRequest( + final @NotNull Map request, + final boolean includeDocument, + final boolean includeVariables) { + final @NotNull Map filtered = new LinkedHashMap<>(request); + if (!includeDocument) { + filtered.remove("query"); + } + if (!includeVariables) { + filtered.remove("variables"); + } + return filtered; + } } diff --git a/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt index 06f385320cf..c6d767359ef 100644 --- a/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt @@ -35,8 +35,48 @@ class GraphqlUtilsTest { } } + @Test + fun `filters documents from a batched GraphQL request body`() { + val options = SentryOptions().also { it.dataCollection.graphql.setDocument(false) } + + val result = GraphqlUtils.filterRequestBody(BATCH_REQUEST_BODY, options) + + assertThat(result).isNotNull() + JsonObjectReader(StringReader(result)).use { reader -> + @Suppress("UNCHECKED_CAST") val body = reader.nextObjectOrNull() as List> + assertThat(body).hasSize(2) + assertThat(body[0]).containsEntry("operationName", "GetUser") + assertThat(body[0]).containsEntry("variables", mapOf("id" to "123")) + assertThat(body[0]).doesNotContainKey("query") + assertThat(body[1]).containsEntry("operationName", "GetTeam") + assertThat(body[1]).containsEntry("variables", mapOf("slug" to "sdk")) + assertThat(body[1]).doesNotContainKey("query") + } + } + + @Test + fun `filters variables from a batched GraphQL request body`() { + val options = SentryOptions().also { it.dataCollection.graphql.setVariables(false) } + + val result = GraphqlUtils.filterRequestBody(BATCH_REQUEST_BODY, options) + + assertThat(result).isNotNull() + JsonObjectReader(StringReader(result)).use { reader -> + @Suppress("UNCHECKED_CAST") val body = reader.nextObjectOrNull() as List> + assertThat(body).hasSize(2) + assertThat(body[0]).containsEntry("operationName", "GetUser") + assertThat(body[0]).containsEntry("query", "query { viewer { name } }") + assertThat(body[0]).doesNotContainKey("variables") + assertThat(body[1]).containsEntry("operationName", "GetTeam") + assertThat(body[1]).containsEntry("query", "query { team { name } }") + assertThat(body[1]).doesNotContainKey("variables") + } + } + private companion object { const val REQUEST_BODY = """{"operationName":"GetUser","variables":{"id":"123"},"query":"query { viewer { name } }"}""" + const val BATCH_REQUEST_BODY = + """[{"operationName":"GetUser","variables":{"id":"123"},"query":"query { viewer { name } }"},{"operationName":"GetTeam","variables":{"slug":"sdk"},"query":"query { team { name } }"}]""" } } From 01fb6b821d3de226c91b79000f9579da0c39e7bb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 31 Aug 2026 11:42:16 +0200 Subject: [PATCH 42/63] test(graphql): Cover malformed batched request body entries Verify GraphQL request filtering fails closed when a batch contains a non-object entry. Refs #5666 Co-Authored-By: Claude --- sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt index c6d767359ef..ea72368e74d 100644 --- a/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt @@ -73,6 +73,15 @@ class GraphqlUtilsTest { } } + @Test + fun `returns null for a batched GraphQL request body containing a non-object entry`() { + val options = SentryOptions().also { it.dataCollection.graphql.setDocument(false) } + + val result = GraphqlUtils.filterRequestBody("""[$REQUEST_BODY,"unexpected"]""", options) + + assertThat(result).isNull() + } + private companion object { const val REQUEST_BODY = """{"operationName":"GetUser","variables":{"id":"123"},"query":"query { viewer { name } }"}""" From 8afc27f1950633f49a87e3a3b3e18346e523ad5a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 31 Aug 2026 14:20:02 +0200 Subject: [PATCH 43/63] ref(core): Clarify forced Data Collection configuration Rename the internal override marker to explain that it forces an empty Data Collection object into explicit mode. Align the related tests with the clarified semantics. Refs #5666 Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/DataCollection.java | 9 +++++---- sentry/src/test/java/io/sentry/DataCollectionTest.kt | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/sentry/src/main/java/io/sentry/DataCollection.java b/sentry/src/main/java/io/sentry/DataCollection.java index c798dfa032a..0df43e1bd0a 100644 --- a/sentry/src/main/java/io/sentry/DataCollection.java +++ b/sentry/src/main/java/io/sentry/DataCollection.java @@ -10,7 +10,8 @@ /** Configures data that the SDK collects automatically. */ public final class DataCollection { - private boolean overridden; + // Forces Data Collection to be used even when no individual option has been configured. + private boolean forceDataCollection; private @Nullable Boolean userInfo; private @Nullable KeyValueCollectionBehavior cookies; private @Nullable KeyValueCollectionBehavior urlQueryParams; @@ -23,8 +24,8 @@ public DataCollection() { this(true); } - DataCollection(final boolean overridden) { - this.overridden = overridden; + DataCollection(final boolean forceDataCollection) { + this.forceDataCollection = forceDataCollection; } public @Nullable Boolean getUserInfo() { @@ -82,7 +83,7 @@ public void setDatabaseQueryData(final boolean databaseQueryData) { @ApiStatus.Internal boolean isExplicitlyConfigured() { - return overridden + return forceDataCollection || userInfo != null || cookies != null || urlQueryParams != null diff --git a/sentry/src/test/java/io/sentry/DataCollectionTest.kt b/sentry/src/test/java/io/sentry/DataCollectionTest.kt index ffb47458d7a..54366a8e136 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionTest.kt @@ -6,7 +6,7 @@ import kotlin.test.assertFailsWith class DataCollectionTest { @Test - fun `public constructor creates explicit empty configuration`() { + fun `public constructor forces Data Collection for empty configuration`() { val dataCollection = DataCollection() assertThat(dataCollection.userInfo).isNull() @@ -22,7 +22,7 @@ class DataCollectionTest { } @Test - fun `SDK-owned configuration starts unconfigured`() { + fun `SDK-owned configuration does not force Data Collection`() { val dataCollection = DataCollection(false) assertThat(dataCollection.isExplicitlyConfigured()).isFalse() From 140a66638e6a15f553d193f87ca6122a2b1f9c64 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 31 Aug 2026 14:37:33 +0200 Subject: [PATCH 44/63] test(core): Clarify Data Collection resolver scenarios Separate legacy sendDefaultPii fallback coverage from configured Data Collection behavior. Give each resolver test a name that describes one configuration state. Refs #5666 Co-Authored-By: Claude --- .../io/sentry/DataCollectionResolverTest.kt | 64 +++++++++++++++---- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt index 1ad9b4c8112..adc7649d2a5 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -42,7 +42,7 @@ class DataCollectionResolverTest { } @Test - fun `user info override takes precedence over sendDefaultPii`() { + fun `user info uses configured Data Collection value`() { val options = SentryOptions().apply { isSendDefaultPii = true } options.dataCollection.setUserInfo(false) @@ -68,25 +68,53 @@ class DataCollectionResolverTest { } @Test - fun `database query data falls back to sendDefaultPii and override takes precedence`() { - val options = SentryOptions().apply { isSendDefaultPii = true } + fun `database query data uses sendDefaultPii when Data Collection is absent`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isFalse() + + options.isSendDefaultPii = true assertThat(options.dataCollectionResolver.isDatabaseQueryData).isTrue() + } + + @Test + fun `database query data uses configured Data Collection value`() { + val options = SentryOptions().apply { isSendDefaultPii = true } options.dataCollection.setDatabaseQueryData(false) assertThat(options.dataCollectionResolver.isDatabaseQueryData).isFalse() + + options.isSendDefaultPii = false + options.dataCollection.setDatabaseQueryData(true) + + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isTrue() } @Test - fun `GraphQL document falls back to sendDefaultPii and override takes precedence`() { - val options = SentryOptions().apply { isSendDefaultPii = true } + fun `GraphQL document uses sendDefaultPii when Data Collection is absent`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isGraphqlDocument).isFalse() + + options.isSendDefaultPii = true assertThat(options.dataCollectionResolver.isGraphqlDocument).isTrue() + } + + @Test + fun `GraphQL document uses configured Data Collection value`() { + val options = SentryOptions().apply { isSendDefaultPii = true } options.dataCollection.graphql.setDocument(false) assertThat(options.dataCollectionResolver.isGraphqlDocument).isFalse() + + options.isSendDefaultPii = false + options.dataCollection.graphql.setDocument(true) + + assertThat(options.dataCollectionResolver.isGraphqlDocument).isTrue() } @Test @@ -115,7 +143,7 @@ class DataCollectionResolverTest { } @Test - fun `cookies override takes precedence over sendDefaultPii`() { + fun `cookies use configured Data Collection behavior`() { val options = SentryOptions().apply { isSendDefaultPii = false } val behavior = KeyValueCollectionBehavior.allowList("language", "theme") @@ -133,7 +161,7 @@ class DataCollectionResolverTest { } @Test - fun `URL query params override takes precedence`() { + fun `URL query params use configured Data Collection behavior`() { val options = SentryOptions() val behavior = KeyValueCollectionBehavior.allowList("language", "theme") @@ -151,7 +179,7 @@ class DataCollectionResolverTest { } @Test - fun `HTTP request headers override takes precedence`() { + fun `HTTP request headers use configured Data Collection behavior`() { val options = SentryOptions() val behavior = KeyValueCollectionBehavior.allowList("content-type") @@ -169,7 +197,7 @@ class DataCollectionResolverTest { } @Test - fun `HTTP response headers override takes precedence`() { + fun `HTTP response headers use configured Data Collection behavior`() { val options = SentryOptions() val behavior = KeyValueCollectionBehavior.off() @@ -219,13 +247,27 @@ class DataCollectionResolverTest { } @Test - fun `GraphQL variables fall back to sendDefaultPii and override takes precedence`() { - val options = SentryOptions().apply { isSendDefaultPii = true } + fun `GraphQL variables use sendDefaultPii when Data Collection is absent`() { + val options = SentryOptions() + + assertThat(options.dataCollectionResolver.isGraphqlVariables).isFalse() + + options.isSendDefaultPii = true assertThat(options.dataCollectionResolver.isGraphqlVariables).isTrue() + } + + @Test + fun `GraphQL variables use configured Data Collection value`() { + val options = SentryOptions().apply { isSendDefaultPii = true } options.dataCollection.graphql.setVariables(false) assertThat(options.dataCollectionResolver.isGraphqlVariables).isFalse() + + options.isSendDefaultPii = false + options.dataCollection.graphql.setVariables(true) + + assertThat(options.dataCollectionResolver.isGraphqlVariables).isTrue() } } From f6fa18cb3d0add04aa692ef58149da8468cf61b9 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 31 Aug 2026 15:04:29 +0200 Subject: [PATCH 45/63] test(apollo): Cover request header filtering in Apollo 4 Verify that Apollo 4 applies configured Data Collection deny-list behavior to captured request headers across both supported execution paths. Refs #5666 Co-Authored-By: Claude --- ...yApollo4BuilderExtensionsClientErrorsTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt index abf6b52e7d4..6928fe4a8fb 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -356,6 +356,23 @@ abstract class SentryApollo4BuilderExtensionsClientErrorsTest( ) } + @Test + fun `data collection filters request headers`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) { + dataCollection.httpHeaders.request = KeyValueCollectionBehavior.denyList("accept") + } + executeQuery(sut) + + verify(fixture.scopes) + .captureEvent( + check { + assertEquals("[Filtered]", it.request!!.headers?.get("Accept")) + }, + any(), + ) + } + @Test fun `data collection can disable request headers`() { val sut = From a3ab073bc64022089933f793f2cb3b405c1a72b0 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 1 Sep 2026 13:34:55 +0200 Subject: [PATCH 46/63] feat(core): Add Data Collection external options Parse flattened Data Collection settings from properties, system properties, and environment variables. Merge only configured values so omitted settings retain their documented or legacy behavior. Refs #5666 --- sentry/api/sentry.api | 2 + .../main/java/io/sentry/ExternalOptions.java | 105 ++++++++++++++++++ .../main/java/io/sentry/SentryOptions.java | 37 ++++++ .../java/io/sentry/ExternalOptionsTest.kt | 94 ++++++++++++++++ .../test/java/io/sentry/SentryOptionsTest.kt | 81 ++++++++++++++ 5 files changed, 319 insertions(+) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ed42a1c3ddc..3bebc5e6d68 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -547,6 +547,7 @@ public final class io/sentry/ExternalOptions { public fun getBundleIds ()Ljava/util/Set; public fun getContextTags ()Ljava/util/List; public fun getCron ()Lio/sentry/SentryOptions$Cron; + public fun getDataCollection ()Lio/sentry/DataCollection; public fun getDebug ()Ljava/lang/Boolean; public fun getDist ()Ljava/lang/String; public fun getDsn ()Ljava/lang/String; @@ -596,6 +597,7 @@ public final class io/sentry/ExternalOptions { public fun isStrictTraceContinuation ()Ljava/lang/Boolean; public fun setCaptureOpenTelemetryEvents (Ljava/lang/Boolean;)V public fun setCron (Lio/sentry/SentryOptions$Cron;)V + public fun setDataCollection (Lio/sentry/DataCollection;)V public fun setDebug (Ljava/lang/Boolean;)V public fun setDist (Ljava/lang/String;)V public fun setDsn (Ljava/lang/String;)V diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 4e44ea422ec..1232536c7d8 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -55,6 +55,7 @@ public final class ExternalOptions { private @Nullable Boolean sendModules; private @Nullable Boolean sendDefaultPii; + private @Nullable DataCollection dataCollection; private @Nullable Boolean enableBackpressureHandling; private @Nullable Boolean enableDatabaseTransactionTracing; private @Nullable Boolean enableCacheTracing; @@ -157,6 +158,7 @@ public final class ExternalOptions { options.setSendModules(propertiesProvider.getBooleanProperty("send-modules")); options.setSendDefaultPii(propertiesProvider.getBooleanProperty("send-default-pii")); + options.setDataCollection(parseDataCollection(propertiesProvider)); options.setIgnoredCheckIns(propertiesProvider.getListOrNull("ignored-checkins")); options.setIgnoredTransactions(propertiesProvider.getListOrNull("ignored-transactions")); @@ -246,6 +248,101 @@ public final class ExternalOptions { return options; } + private static @Nullable DataCollection parseDataCollection( + final @NotNull PropertiesProvider propertiesProvider) { + final DataCollection dataCollection = new DataCollection(false); + + final Boolean userInfo = propertiesProvider.getBooleanProperty("data-collection.user-info"); + if (userInfo != null) { + dataCollection.setUserInfo(userInfo); + } + + final Set httpBodies = parseHttpBodies(propertiesProvider); + if (httpBodies != null) { + dataCollection.setHttpBodies(httpBodies); + } + + final KeyValueCollectionBehavior cookies = + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.cookies"); + if (cookies != null) { + dataCollection.setCookies(cookies); + } + + final KeyValueCollectionBehavior requestHeaders = + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.http-headers.request"); + if (requestHeaders != null) { + dataCollection.getHttpHeaders().setRequest(requestHeaders); + } + + final KeyValueCollectionBehavior responseHeaders = + parseKeyValueCollectionBehavior( + propertiesProvider, "data-collection.http-headers.response"); + if (responseHeaders != null) { + dataCollection.getHttpHeaders().setResponse(responseHeaders); + } + + final KeyValueCollectionBehavior queryParams = + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.query-params"); + if (queryParams != null) { + dataCollection.setUrlQueryParams(queryParams); + } + + final Boolean graphqlDocument = + propertiesProvider.getBooleanProperty("data-collection.graphql.document"); + if (graphqlDocument != null) { + dataCollection.getGraphql().setDocument(graphqlDocument); + } + + final Boolean graphqlVariables = + propertiesProvider.getBooleanProperty("data-collection.graphql.variables"); + if (graphqlVariables != null) { + dataCollection.getGraphql().setVariables(graphqlVariables); + } + + final Boolean databaseQueryData = + propertiesProvider.getBooleanProperty("data-collection.database-query-data"); + if (databaseQueryData != null) { + dataCollection.setDatabaseQueryData(databaseQueryData); + } + + return dataCollection.isExplicitlyConfigured() ? dataCollection : null; + } + + private static @Nullable Set parseHttpBodies( + final @NotNull PropertiesProvider propertiesProvider) { + final List bodyTypes = propertiesProvider.getListOrNull("data-collection.http-bodies"); + if (bodyTypes == null) { + return null; + } + if (bodyTypes.size() == 1 && bodyTypes.get(0).isEmpty()) { + return Collections.emptySet(); + } + + final Set httpBodies = EnumSet.noneOf(HttpBodyType.class); + for (final String bodyType : bodyTypes) { + httpBodies.add(HttpBodyType.valueOf(bodyType.toUpperCase(Locale.ROOT))); + } + return httpBodies; + } + + private static @Nullable KeyValueCollectionBehavior parseKeyValueCollectionBehavior( + final @NotNull PropertiesProvider propertiesProvider, final @NotNull String property) { + final String modeValue = propertiesProvider.getProperty(property + ".mode"); + final List terms = propertiesProvider.getListOrNull(property + ".terms"); + if (modeValue == null && terms == null) { + return null; + } + + final KeyValueCollectionBehavior behavior = new KeyValueCollectionBehavior(); + if (modeValue != null) { + behavior.setMode(KeyValueCollectionBehavior.Mode.valueOf(modeValue.toUpperCase(Locale.ROOT))); + } + if (terms != null) { + behavior.setTerms(terms); + } + return behavior; + } + public @Nullable String getDsn() { return dsn; } @@ -501,6 +598,14 @@ public void setSendDefaultPii(final @Nullable Boolean sendDefaultPii) { this.sendDefaultPii = sendDefaultPii; } + public @Nullable DataCollection getDataCollection() { + return dataCollection; + } + + public void setDataCollection(final @Nullable DataCollection dataCollection) { + this.dataCollection = dataCollection; + } + public void setIgnoredCheckIns(final @Nullable List ignoredCheckIns) { this.ignoredCheckIns = ignoredCheckIns; } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index e312fe94c47..bde387d8f0b 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3627,6 +3627,9 @@ public void merge(final @NotNull ExternalOptions options) { if (options.isSendDefaultPii() != null) { setSendDefaultPii(options.isSendDefaultPii()); } + if (options.getDataCollection() != null) { + mergeDataCollection(options.getDataCollection()); + } if (options.isCaptureOpenTelemetryEvents() != null) { setCaptureOpenTelemetryEvents(options.isCaptureOpenTelemetryEvents()); } @@ -3692,6 +3695,40 @@ public void merge(final @NotNull ExternalOptions options) { } } + private void mergeDataCollection(final @NotNull DataCollection externalDataCollection) { + if (externalDataCollection.getUserInfo() != null) { + dataCollection.setUserInfo(externalDataCollection.getUserInfo()); + } + if (externalDataCollection.getHttpBodies() != null) { + dataCollection.setHttpBodies(externalDataCollection.getHttpBodies()); + } + if (externalDataCollection.getCookies() != null) { + dataCollection.setCookies(externalDataCollection.getCookies()); + } + if (externalDataCollection.getHttpHeaders().getRequest() != null) { + dataCollection + .getHttpHeaders() + .setRequest(externalDataCollection.getHttpHeaders().getRequest()); + } + if (externalDataCollection.getHttpHeaders().getResponse() != null) { + dataCollection + .getHttpHeaders() + .setResponse(externalDataCollection.getHttpHeaders().getResponse()); + } + if (externalDataCollection.getUrlQueryParams() != null) { + dataCollection.setUrlQueryParams(externalDataCollection.getUrlQueryParams()); + } + if (externalDataCollection.getGraphql().getDocument() != null) { + dataCollection.getGraphql().setDocument(externalDataCollection.getGraphql().getDocument()); + } + if (externalDataCollection.getGraphql().getVariables() != null) { + dataCollection.getGraphql().setVariables(externalDataCollection.getGraphql().getVariables()); + } + if (externalDataCollection.getDatabaseQueryData() != null) { + dataCollection.setDatabaseQueryData(externalDataCollection.getDatabaseQueryData()); + } + } + private @NotNull SdkVersion createSdkVersion() { final String version = BuildConfig.VERSION_NAME; final SdkVersion sdkVersion = new SdkVersion(BuildConfig.SENTRY_JAVA_SDK_NAME, version); diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index fee707d31f3..27056593a65 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -1,9 +1,11 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.config.PropertiesProviderFactory import java.lang.RuntimeException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -15,6 +17,98 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify class ExternalOptionsTest { + @Test + fun `does not create data collection when external properties are absent`() { + withPropertiesFile { assertThat(it.dataCollection).isNull() } + } + + @Test + fun `creates data collection using external properties`() { + withPropertiesFile( + listOf( + "data-collection.user-info=false", + "data-collection.http-bodies=incoming_request,outgoing_response", + "data-collection.cookies.mode=deny_list", + "data-collection.cookies.terms=authorization,session", + "data-collection.http-headers.request.mode=allow_list", + "data-collection.http-headers.request.terms=x-request-id,content-type", + "data-collection.http-headers.response.mode=off", + "data-collection.query-params.terms=search", + "data-collection.graphql.document=false", + "data-collection.graphql.variables=true", + "data-collection.database-query-data=false", + ) + ) { options -> + val dataCollection = options.dataCollection + + assertThat(dataCollection).isNotNull() + assertThat(dataCollection!!.userInfo).isFalse() + assertThat(dataCollection.httpBodies) + .containsExactly(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + assertThat(dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList("authorization", "session")) + assertThat(dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("x-request-id", "content-type")) + assertThat(dataCollection.httpHeaders.response).isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(dataCollection.urlQueryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList("search")) + assertThat(dataCollection.graphql.document).isFalse() + assertThat(dataCollection.graphql.variables).isTrue() + assertThat(dataCollection.databaseQueryData).isFalse() + } + } + + @Test + fun `empty HTTP bodies externally disables body collection`() { + withPropertiesFile("data-collection.http-bodies=") { options -> + assertThat(options.dataCollection).isNotNull() + assertThat(options.dataCollection!!.httpBodies).isEmpty() + } + } + + @Test + fun `invalid HTTP body type fails external parsing`() { + assertFailsWith { + withPropertiesFile("data-collection.http-bodies=invalid") {} + } + } + + @Test + fun `invalid collection mode fails external parsing`() { + assertFailsWith { + withPropertiesFile("data-collection.cookies.mode=invalid") {} + } + } + + @Test + fun `data collection booleans use default external parsing`() { + withPropertiesFile( + listOf( + "data-collection.user-info=invalid", + "data-collection.graphql.document=invalid", + "data-collection.graphql.variables=invalid", + "data-collection.database-query-data=invalid", + ) + ) { options -> + assertThat(options.dataCollection!!.userInfo).isFalse() + assertThat(options.dataCollection!!.graphql.document).isFalse() + assertThat(options.dataCollection!!.graphql.variables).isFalse() + assertThat(options.dataCollection!!.databaseQueryData).isFalse() + } + } + + @Test + fun `external data collection takes precedence over external send default PII`() { + withPropertiesFile(listOf("send-default-pii=false", "data-collection.cookies.mode=off")) { + externalOptions -> + val options = SentryOptions().apply { merge(externalOptions) } + + assertThat(options.isSendDefaultPii).isFalse() + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(options.dataCollectionResolver.cookies).isEqualTo(KeyValueCollectionBehavior.off()) + } + } + @Test fun `creates options with proxy using external properties`() { withPropertiesFile( diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index d5c7e6f3c7e..fa3f3296fc5 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -82,6 +82,87 @@ class SentryOptionsTest { assertThat(options.dataCollection.userInfo).isFalse() } + @Test + fun `merging absent external data collection preserves legacy mode`() { + val options = SentryOptions() + + options.merge(ExternalOptions()) + + assertThat(options.dataCollection.isExplicitlyConfigured()).isFalse() + } + + @Test + fun `merging external data collection applies only configured values`() { + val options = + SentryOptions().apply { + dataCollection.setUserInfo(false) + dataCollection.cookies = KeyValueCollectionBehavior.allowList("safe") + } + val externalOptions = + ExternalOptions().apply { + dataCollection = DataCollection().apply { graphql.setVariables(false) } + } + + options.merge(externalOptions) + + assertThat(options.dataCollection.userInfo).isFalse() + assertThat(options.dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.allowList("safe")) + assertThat(options.dataCollection.graphql.variables).isFalse() + } + + @Test + fun `merging external data collection applies every supported value`() { + val externalDataCollection = + DataCollection().apply { + setUserInfo(false) + httpBodies = setOf(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + cookies = KeyValueCollectionBehavior.denyList("cookie") + httpHeaders.request = KeyValueCollectionBehavior.allowList("request") + httpHeaders.response = KeyValueCollectionBehavior.off() + urlQueryParams = KeyValueCollectionBehavior.denyList("query") + graphql.setDocument(false) + graphql.setVariables(false) + setDatabaseQueryData(false) + } + val options = SentryOptions() + + options.merge(ExternalOptions().apply { dataCollection = externalDataCollection }) + + assertThat(options.dataCollection.userInfo).isFalse() + assertThat(options.dataCollection.httpBodies) + .containsExactly(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + assertThat(options.dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList("cookie")) + assertThat(options.dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("request")) + assertThat(options.dataCollection.httpHeaders.response) + .isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(options.dataCollection.urlQueryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList("query")) + assertThat(options.dataCollection.graphql.document).isFalse() + assertThat(options.dataCollection.graphql.variables).isFalse() + assertThat(options.dataCollection.databaseQueryData).isFalse() + } + + @Test + fun `external data collection takes precedence over send default PII`() { + val externalOptions = + ExternalOptions().apply { + isSendDefaultPii = false + dataCollection = DataCollection().apply { cookies = KeyValueCollectionBehavior.off() } + } + val options = SentryOptions() + + options.merge(externalOptions) + + assertThat(options.isSendDefaultPii).isFalse() + assertThat(options.dataCollection.isExplicitlyConfigured()).isTrue() + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isTrue() + assertThat(options.dataCollectionResolver.cookies).isEqualTo(KeyValueCollectionBehavior.off()) + } + @Test fun `when options is initialized, logger is not null`() { assertNotNull(SentryOptions().logger) From e4ff2f65e1744602126ddc513304db18ef3467bb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Sep 2026 06:16:03 +0200 Subject: [PATCH 47/63] fix(core): Use URL query parameter external option name Align the flattened external configuration key with the Data Collection option name used by the specification and Android manifest configuration.\n\nRefs #5666\nCo-Authored-By: Claude --- sentry/src/main/java/io/sentry/ExternalOptions.java | 2 +- sentry/src/test/java/io/sentry/ExternalOptionsTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 1232536c7d8..abcb229e7e5 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -282,7 +282,7 @@ public final class ExternalOptions { } final KeyValueCollectionBehavior queryParams = - parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.query-params"); + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.url-query-params"); if (queryParams != null) { dataCollection.setUrlQueryParams(queryParams); } diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 27056593a65..b4b800d589d 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -33,7 +33,7 @@ class ExternalOptionsTest { "data-collection.http-headers.request.mode=allow_list", "data-collection.http-headers.request.terms=x-request-id,content-type", "data-collection.http-headers.response.mode=off", - "data-collection.query-params.terms=search", + "data-collection.url-query-params.terms=search", "data-collection.graphql.document=false", "data-collection.graphql.variables=true", "data-collection.database-query-data=false", From e5497307f0765cca865b0dc7a599bcc187b4269e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Sep 2026 06:16:35 +0200 Subject: [PATCH 48/63] feat(android): Add Data Collection manifest options Parse flattened Data Collection metadata while preserving existing option values for fields omitted from the manifest. Expose internal configuration-state helpers across SDK modules so Android can distinguish absent metadata from explicit settings.\n\nRefs #5666\nCo-Authored-By: Claude --- sentry-android-core/build.gradle.kts | 1 + .../android/core/ManifestMetadataReader.java | 173 ++++++++++++++++++ .../core/ManifestMetadataReaderTest.kt | 136 ++++++++++++++ sentry/api/sentry.api | 2 + .../main/java/io/sentry/DataCollection.java | 5 +- 5 files changed, 315 insertions(+), 2 deletions(-) diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 0388b7de486..0b9fa89b543 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -110,6 +110,7 @@ dependencies { testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.test.runner) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(projects.sentryTestSupport) 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..b3c3b86167b 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 @@ -3,8 +3,11 @@ import android.content.Context; import android.content.pm.ApplicationInfo; import android.os.Bundle; +import io.sentry.DataCollection; +import io.sentry.HttpBodyType; import io.sentry.ILogger; import io.sentry.InitPriority; +import io.sentry.KeyValueCollectionBehavior; import io.sentry.ProfileLifecycle; import io.sentry.ScreenshotStrategyType; import io.sentry.SentryFeedbackOptions; @@ -16,8 +19,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.EnumSet; import java.util.List; import java.util.Locale; +import java.util.Set; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -102,6 +107,22 @@ final class ManifestMetadataReader { static final String SEND_DEFAULT_PII = "io.sentry.send-default-pii"; + static final String DATA_COLLECTION_USER_INFO = "io.sentry.data-collection.user-info"; + static final String DATA_COLLECTION_HTTP_BODIES = "io.sentry.data-collection.http-bodies"; + static final String DATA_COLLECTION_COOKIES = "io.sentry.data-collection.cookies"; + static final String DATA_COLLECTION_HTTP_REQUEST_HEADERS = + "io.sentry.data-collection.http-headers.request"; + static final String DATA_COLLECTION_HTTP_RESPONSE_HEADERS = + "io.sentry.data-collection.http-headers.response"; + static final String DATA_COLLECTION_URL_QUERY_PARAMS = + "io.sentry.data-collection.url-query-params"; + static final String DATA_COLLECTION_GRAPHQL_DOCUMENT = + "io.sentry.data-collection.graphql.document"; + static final String DATA_COLLECTION_GRAPHQL_VARIABLES = + "io.sentry.data-collection.graphql.variables"; + static final String DATA_COLLECTION_DATABASE_QUERY_DATA = + "io.sentry.data-collection.database-query-data"; + static final String PERFORM_FRAMES_TRACKING = "io.sentry.traces.frames-tracking"; static final String SENTRY_GRADLE_PLUGIN_INTEGRATIONS = "io.sentry.gradle-plugin-integrations"; @@ -761,6 +782,12 @@ static void applyMetadata( options.setEnableAnrFingerprinting( readBool( metadata, logger, ENABLE_ANR_FINGERPRINTING, options.isEnableAnrFingerprinting())); + + final @Nullable DataCollection dataCollection = + readDataCollection(metadata, logger, options.getDataCollection()); + if (dataCollection != null) { + mergeDataCollection(options.getDataCollection(), dataCollection); + } } options .getLogger() @@ -773,6 +800,152 @@ static void applyMetadata( } } + private static @Nullable DataCollection readDataCollection( + final @NotNull Bundle metadata, + final @NotNull ILogger logger, + final @NotNull DataCollection currentDataCollection) { + final @NotNull DataCollection dataCollection = new DataCollection(false); + + if (metadata.containsKey(DATA_COLLECTION_USER_INFO)) { + dataCollection.setUserInfo(readBool(metadata, logger, DATA_COLLECTION_USER_INFO, false)); + } + + if (metadata.containsKey(DATA_COLLECTION_HTTP_BODIES)) { + dataCollection.setHttpBodies(readHttpBodyTypes(metadata, logger)); + } + + final @Nullable KeyValueCollectionBehavior cookies = + readKeyValueCollectionBehavior( + metadata, logger, DATA_COLLECTION_COOKIES, currentDataCollection.getCookies()); + if (cookies != null) { + dataCollection.setCookies(cookies); + } + + final @Nullable KeyValueCollectionBehavior requestHeaders = + readKeyValueCollectionBehavior( + metadata, + logger, + DATA_COLLECTION_HTTP_REQUEST_HEADERS, + currentDataCollection.getHttpHeaders().getRequest()); + if (requestHeaders != null) { + dataCollection.getHttpHeaders().setRequest(requestHeaders); + } + + final @Nullable KeyValueCollectionBehavior responseHeaders = + readKeyValueCollectionBehavior( + metadata, + logger, + DATA_COLLECTION_HTTP_RESPONSE_HEADERS, + currentDataCollection.getHttpHeaders().getResponse()); + if (responseHeaders != null) { + dataCollection.getHttpHeaders().setResponse(responseHeaders); + } + + final @Nullable KeyValueCollectionBehavior urlQueryParams = + readKeyValueCollectionBehavior( + metadata, + logger, + DATA_COLLECTION_URL_QUERY_PARAMS, + currentDataCollection.getUrlQueryParams()); + if (urlQueryParams != null) { + dataCollection.setUrlQueryParams(urlQueryParams); + } + + if (metadata.containsKey(DATA_COLLECTION_GRAPHQL_DOCUMENT)) { + dataCollection + .getGraphql() + .setDocument(readBool(metadata, logger, DATA_COLLECTION_GRAPHQL_DOCUMENT, false)); + } + + if (metadata.containsKey(DATA_COLLECTION_GRAPHQL_VARIABLES)) { + dataCollection + .getGraphql() + .setVariables(readBool(metadata, logger, DATA_COLLECTION_GRAPHQL_VARIABLES, false)); + } + + if (metadata.containsKey(DATA_COLLECTION_DATABASE_QUERY_DATA)) { + dataCollection.setDatabaseQueryData( + readBool(metadata, logger, DATA_COLLECTION_DATABASE_QUERY_DATA, false)); + } + + return dataCollection.isExplicitlyConfigured() ? dataCollection : null; + } + + private static void mergeDataCollection( + final @NotNull DataCollection target, final @NotNull DataCollection source) { + if (source.getUserInfo() != null) { + target.setUserInfo(source.getUserInfo()); + } + if (source.getHttpBodies() != null) { + target.setHttpBodies(source.getHttpBodies()); + } + if (source.getCookies() != null) { + target.setCookies(source.getCookies()); + } + if (source.getHttpHeaders().getRequest() != null) { + target.getHttpHeaders().setRequest(source.getHttpHeaders().getRequest()); + } + if (source.getHttpHeaders().getResponse() != null) { + target.getHttpHeaders().setResponse(source.getHttpHeaders().getResponse()); + } + if (source.getUrlQueryParams() != null) { + target.setUrlQueryParams(source.getUrlQueryParams()); + } + if (source.getGraphql().getDocument() != null) { + target.getGraphql().setDocument(source.getGraphql().getDocument()); + } + if (source.getGraphql().getVariables() != null) { + target.getGraphql().setVariables(source.getGraphql().getVariables()); + } + if (source.getDatabaseQueryData() != null) { + target.setDatabaseQueryData(source.getDatabaseQueryData()); + } + } + + private static @NotNull Set readHttpBodyTypes( + final @NotNull Bundle metadata, final @NotNull ILogger logger) { + final @Nullable List bodyTypes = + readList(metadata, logger, DATA_COLLECTION_HTTP_BODIES); + if (bodyTypes == null || (bodyTypes.size() == 1 && bodyTypes.get(0).isEmpty())) { + return Collections.emptySet(); + } + + final @NotNull Set result = EnumSet.noneOf(HttpBodyType.class); + for (final String bodyType : bodyTypes) { + result.add(HttpBodyType.valueOf(bodyType.toUpperCase(Locale.ROOT))); + } + return result; + } + + private static @Nullable KeyValueCollectionBehavior readKeyValueCollectionBehavior( + final @NotNull Bundle metadata, + final @NotNull ILogger logger, + final @NotNull String key, + final @Nullable KeyValueCollectionBehavior currentBehavior) { + final @NotNull String modeKey = key + ".mode"; + final @NotNull String termsKey = key + ".terms"; + if (!metadata.containsKey(modeKey) && !metadata.containsKey(termsKey)) { + return null; + } + + final @NotNull KeyValueCollectionBehavior behavior = new KeyValueCollectionBehavior(); + if (currentBehavior != null) { + behavior.setMode(currentBehavior.getMode()); + behavior.setTerms(currentBehavior.getTerms()); + } + if (metadata.containsKey(modeKey)) { + final @Nullable String mode = readString(metadata, logger, modeKey, null); + if (mode != null) { + behavior.setMode(KeyValueCollectionBehavior.Mode.valueOf(mode.toUpperCase(Locale.ROOT))); + } + } + if (metadata.containsKey(termsKey)) { + final @Nullable List terms = readList(metadata, logger, termsKey); + behavior.setTerms(terms == null ? Collections.emptyList() : terms); + } + return behavior; + } + private static boolean readBool( final @NotNull Bundle metadata, final @NotNull ILogger logger, 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..2ad8a80c0eb 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 @@ -4,8 +4,11 @@ import android.content.Context import android.os.Bundle import androidx.core.os.bundleOf import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.FilterString +import io.sentry.HttpBodyType import io.sentry.ILogger +import io.sentry.KeyValueCollectionBehavior import io.sentry.ProfileLifecycle import io.sentry.SentryLevel import io.sentry.SentryReplayOptions @@ -1409,6 +1412,139 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isSendDefaultPii) } + @Test + fun `applyMetadata preserves legacy data collection when metadata is absent`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isFalse() + } + + @Test + fun `applyMetadata reads data collection options`() { + val bundle = + bundleOf( + ManifestMetadataReader.DATA_COLLECTION_USER_INFO to false, + ManifestMetadataReader.DATA_COLLECTION_HTTP_BODIES to "incoming_request,outgoing_response", + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".mode" to "deny_list", + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".terms" to "authorization,session", + ManifestMetadataReader.DATA_COLLECTION_HTTP_REQUEST_HEADERS + ".mode" to "allow_list", + ManifestMetadataReader.DATA_COLLECTION_HTTP_REQUEST_HEADERS + ".terms" to + "x-request-id,content-type", + ManifestMetadataReader.DATA_COLLECTION_HTTP_RESPONSE_HEADERS + ".mode" to "off", + ManifestMetadataReader.DATA_COLLECTION_URL_QUERY_PARAMS + ".terms" to "search", + ManifestMetadataReader.DATA_COLLECTION_GRAPHQL_DOCUMENT to false, + ManifestMetadataReader.DATA_COLLECTION_GRAPHQL_VARIABLES to true, + ManifestMetadataReader.DATA_COLLECTION_DATABASE_QUERY_DATA to false, + ) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + val dataCollection = fixture.options.dataCollection + assertThat(dataCollection.userInfo).isFalse() + assertThat(dataCollection.httpBodies) + .containsExactly(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + assertThat(dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList("authorization", "session")) + assertThat(dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("x-request-id", "content-type")) + assertThat(dataCollection.httpHeaders.response).isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(dataCollection.urlQueryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList("search")) + assertThat(dataCollection.graphql.document).isFalse() + assertThat(dataCollection.graphql.variables).isTrue() + assertThat(dataCollection.databaseQueryData).isFalse() + } + + @Test + fun `applyMetadata only overrides explicitly configured data collection options`() { + val dataCollection = + fixture.options.dataCollection.apply { + setUserInfo(true) + setHttpBodies(setOf(HttpBodyType.OUTGOING_REQUEST)) + cookies = KeyValueCollectionBehavior.allowList("existing-cookie") + httpHeaders.request = KeyValueCollectionBehavior.denyList("existing-request-header") + httpHeaders.response = KeyValueCollectionBehavior.allowList("existing-response-header") + urlQueryParams = KeyValueCollectionBehavior.off() + graphql.setDocument(true) + graphql.setVariables(false) + setDatabaseQueryData(true) + } + val bundle = + bundleOf( + ManifestMetadataReader.DATA_COLLECTION_USER_INFO to false, + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".terms" to "manifest-cookie", + ManifestMetadataReader.DATA_COLLECTION_HTTP_REQUEST_HEADERS + ".mode" to "allow_list", + ) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollection).isSameInstanceAs(dataCollection) + assertThat(dataCollection.userInfo).isFalse() + assertThat(dataCollection.httpBodies).containsExactly(HttpBodyType.OUTGOING_REQUEST) + assertThat(dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.allowList("manifest-cookie")) + assertThat(dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("existing-request-header")) + assertThat(dataCollection.httpHeaders.response) + .isEqualTo(KeyValueCollectionBehavior.allowList("existing-response-header")) + assertThat(dataCollection.urlQueryParams).isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(dataCollection.graphql.document).isTrue() + assertThat(dataCollection.graphql.variables).isFalse() + assertThat(dataCollection.databaseQueryData).isTrue() + } + + @Test + fun `applyMetadata reads empty HTTP bodies as disabled`() { + val bundle = bundleOf(ManifestMetadataReader.DATA_COLLECTION_HTTP_BODIES to "") + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollection.httpBodies).isEmpty() + } + + @Test + fun `applyMetadata data collection takes precedence over send default pii`() { + val bundle = + bundleOf( + ManifestMetadataReader.SEND_DEFAULT_PII to false, + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".mode" to "off", + ) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.isSendDefaultPii).isFalse() + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isTrue() + assertThat(fixture.options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(fixture.options.dataCollectionResolver.cookies) + .isEqualTo(KeyValueCollectionBehavior.off()) + } + + @Test + fun `applyMetadata ignores invalid data collection body type`() { + val bundle = bundleOf(ManifestMetadataReader.DATA_COLLECTION_HTTP_BODIES to "invalid") + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isFalse() + } + + @Test + fun `applyMetadata ignores invalid data collection mode`() { + val bundle = bundleOf(ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".mode" to "invalid") + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isFalse() + } + @Test fun `applyMetadata reads frames tracking flag and keeps default value if not found`() { // Arrange diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 3bebc5e6d68..4012b914c00 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -386,6 +386,7 @@ public final class io/sentry/DataCategory : java/lang/Enum { public final class io/sentry/DataCollection { public fun ()V + public fun (Z)V public fun getCookies ()Lio/sentry/KeyValueCollectionBehavior; public fun getDatabaseQueryData ()Ljava/lang/Boolean; public fun getGraphql ()Lio/sentry/DataCollection$Graphql; @@ -393,6 +394,7 @@ public final class io/sentry/DataCollection { public fun getHttpHeaders ()Lio/sentry/DataCollection$HttpHeaders; public fun getUrlQueryParams ()Lio/sentry/KeyValueCollectionBehavior; public fun getUserInfo ()Ljava/lang/Boolean; + public fun isExplicitlyConfigured ()Z public fun setCookies (Lio/sentry/KeyValueCollectionBehavior;)V public fun setDatabaseQueryData (Z)V public fun setHttpBodies (Ljava/util/Set;)V diff --git a/sentry/src/main/java/io/sentry/DataCollection.java b/sentry/src/main/java/io/sentry/DataCollection.java index c798dfa032a..51399ffc192 100644 --- a/sentry/src/main/java/io/sentry/DataCollection.java +++ b/sentry/src/main/java/io/sentry/DataCollection.java @@ -23,7 +23,8 @@ public DataCollection() { this(true); } - DataCollection(final boolean overridden) { + @ApiStatus.Internal + public DataCollection(final boolean overridden) { this.overridden = overridden; } @@ -81,7 +82,7 @@ public void setDatabaseQueryData(final boolean databaseQueryData) { } @ApiStatus.Internal - boolean isExplicitlyConfigured() { + public boolean isExplicitlyConfigured() { return overridden || userInfo != null || cookies != null From a81ffe767b289209419178f4c29d14bd3217a477 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Sep 2026 10:34:52 +0200 Subject: [PATCH 49/63] fix(core): Reject malformed cookie pairs Validate cookie names and values before applying Data Collection filters. Fail closed for malformed values that could embed additional sensitive cookie pairs while preserving valid quoted and padded values. Refs #5666 Co-Authored-By: Claude --- .../main/java/io/sentry/util/HttpUtils.java | 71 ++++++++++++++++++- .../test/java/io/sentry/util/HttpUtilsTest.kt | 63 ++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index 2b3f60c38dc..e85e3562d46 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -208,8 +208,75 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { } private static boolean isValidCookiePair(final @NotNull String cookie) { - final int separator = cookie.indexOf('='); - return separator >= 0 && !cookie.substring(0, separator).trim().isEmpty(); + final @NotNull String cookiePair = cookie.trim(); + final int separator = cookiePair.indexOf('='); + if (separator <= 0 || !isValidCookieName(cookiePair.substring(0, separator))) { + return false; + } + + final @NotNull String value = cookiePair.substring(separator + 1); + int start = 0; + int end = value.length(); + if (!value.isEmpty() && value.charAt(0) == '"') { + if (value.length() < 2 || value.charAt(value.length() - 1) != '"') { + return false; + } + start++; + end--; + } + + for (int i = start; i < end; i++) { + if (!isCookieOctet(value.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isValidCookieName(final @NotNull String name) { + for (int i = 0; i < name.length(); i++) { + if (!isCookieNameCharacter(name.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isCookieNameCharacter(final char value) { + if ((value >= 'a' && value <= 'z') + || (value >= 'A' && value <= 'Z') + || (value >= '0' && value <= '9')) { + return true; + } + + switch (value) { + case '!': + case '#': + case '$': + case '%': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + default: + return false; + } + } + + private static boolean isCookieOctet(final char value) { + return value == 0x21 + || (value >= 0x23 && value <= 0x2B) + || (value >= 0x2D && value <= 0x3A) + || (value >= 0x3C && value <= 0x5B) + || (value >= 0x5D && value <= 0x7E); } public static @NotNull Map filterHeaders( diff --git a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt index 2a7914628dc..f472c99fdd8 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -133,6 +133,69 @@ class HttpUtilsTest { .isEqualTo("theme=dark;[Filtered];[Filtered]; empty=; sessionId=[Filtered]") } + @Test + fun `cookie filter replaces comma-separated malformed cookies`() { + assertThat( + HttpUtils.filterCookies( + "theme=dark, sessionId=secret", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie filter replaces space-separated malformed cookies`() { + assertThat( + HttpUtils.filterCookies( + "theme=dark sessionId=secret", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie filter preserves valid names and values`() { + val cookies = + "plain=abc123; empty=; base64=YWJjZA==; quoted=\"dark\"; quoted-empty=\"\"; encoded=hello%2Fworld; !#\$%&'*+-.^_`|~=!#\$%&'()*+-./:<=>?@[]^_`{|}~" + + assertThat( + HttpUtils.filterCookies( + cookies, + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo(cookies) + } + + @Test + fun `cookie filter replaces comma-separated malformed cookies in quoted values`() { + assertThat( + HttpUtils.filterCookies( + "theme=\"dark, sessionId=secret\"", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie filter replaces space-separated malformed cookies in quoted values`() { + assertThat( + HttpUtils.filterCookies( + "theme=\"dark sessionId=secret\"", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + @Test fun `cookie allow list never exposes malformed pairs`() { assertThat( From 2296468d890e77768ca4a4f434237f593a61d12a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Sep 2026 11:32:40 +0200 Subject: [PATCH 50/63] revert: fix(android): Apply user info policy to distinct ID This reverts commit b27d61d218059360b2d9482e87a79b6bac6aeeae.\n\nKeep generated installation IDs independent of the userInfo policy. Restore\ndefault generation before programmatic configuration so applications can\ncontinue clearing the distinct ID in the configuration callback.\n\nRefs #5666\n\nCo-Authored-By: Claude --- .../core/AndroidOptionsInitializer.java | 8 ++++ .../io/sentry/android/core/SentryAndroid.java | 8 ---- .../core/AndroidOptionsInitializerTest.kt | 44 ------------------- 3 files changed, 8 insertions(+), 52 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 2a60e5750d7..9cc5cb3df0f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -471,6 +471,14 @@ private static void readDefaultOptionValues( options.addInAppInclude(packageName); } } + + if (options.getDistinctId() == null) { + try { + options.setDistinctId(Installation.id(context)); + } catch (RuntimeException e) { + options.getLogger().log(SentryLevel.ERROR, "Could not generate distinct Id.", e); + } + } } /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 150569dcecf..f27259fd635 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -149,14 +149,6 @@ public static void init( "Error in the 'OptionsConfiguration.configure' callback.", t); } - if (options.getDistinctId() == null - && options.getDataCollectionResolver().isUserInfoWithLegacyAlways()) { - try { - options.setDistinctId(Installation.id(context)); - } catch (RuntimeException e) { - options.getLogger().log(SentryLevel.ERROR, "Could not generate distinct Id.", e); - } - } // if SentryPerformanceProvider was disabled or removed, // we set the app start / sdk init time here instead diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 068b0964bcc..f8724d286f8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -111,12 +111,6 @@ class AndroidOptionsInitializerTest { ) sentryOptions.configureOptions() - if ( - sentryOptions.distinctId == null && - sentryOptions.dataCollectionResolver.isUserInfoWithLegacyAlways - ) { - sentryOptions.distinctId = Installation.id(if (useRealContext) context else mockContext) - } AndroidOptionsInitializer.initializeIntegrationsAndProcessors( sentryOptions, if (useRealContext) context else mockContext, @@ -355,44 +349,6 @@ class AndroidOptionsInitializerTest { installation.deleteOnExit() } - @Test - fun `init should not set generated distinct id when user info is disabled`() { - fixture.initSut(configureOptions = { dataCollection.setUserInfo(false) }) - - assertNull(fixture.sentryOptions.distinctId) - } - - @Test - fun `init should set generated distinct id when user info is enabled`() { - fixture.initSut(configureOptions = { dataCollection.setUserInfo(true) }) - - assertNotNull(fixture.sentryOptions.distinctId) - } - - @Test - fun `init should preserve explicit distinct id when user info is disabled`() { - fixture.initSut( - configureOptions = { - dataCollection.setUserInfo(false) - distinctId = "custom-id" - } - ) - - assertEquals("custom-id", fixture.sentryOptions.distinctId) - } - - @Test - fun `init should set generated distinct id when explicit value is null`() { - fixture.initSut( - configureOptions = { - dataCollection.setUserInfo(true) - distinctId = null - } - ) - - assertNotNull(fixture.sentryOptions.distinctId) - } - @Test fun `init should set proguard uuid id on start`() { fixture.initSut( From 489982db6ef14541cdf279d3271aca9b58bc361d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 7 Sep 2026 07:07:15 +0200 Subject: [PATCH 51/63] fix(core): Skip null filtered cookie headers Avoid adding nullable filter results to cookie header lists so downstream consumers only receive actual header values. Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/util/HttpUtils.java | 6 +++++- sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index e85e3562d46..9574202179f 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -131,7 +131,11 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { final @NotNull List filteredHeaders = new ArrayList<>(); for (final String header : headers) { - filteredHeaders.add(filterCookies(header, behavior, additionalSensitiveCookieNames)); + final @Nullable String filteredHeader = + filterCookies(header, behavior, additionalSensitiveCookieNames); + if (filteredHeader != null) { + filteredHeaders.add(filteredHeader); + } } return filteredHeaders; } diff --git a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt index f472c99fdd8..d90ef519ad5 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -303,6 +303,18 @@ class HttpUtilsTest { .inOrder() } + @Test + fun `cookie header filter skips null header values`() { + assertThat( + HttpUtils.filterCookiesFromHeader( + java.util.Arrays.asList("theme=dark", null), + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .containsExactly("theme=dark") + } + @Test fun `header filter disables collection in off mode`() { val filtered = From 6900fba9c5a6c7c2472adc089bb8c6fec01ca766 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 7 Sep 2026 07:43:20 +0200 Subject: [PATCH 52/63] fix(core): Preserve blank cookie segments Keep empty and whitespace-only cookie segments unchanged instead of replacing them with a filtered marker. Co-Authored-By: Claude --- .../main/java/io/sentry/util/HttpUtils.java | 4 +++ .../test/java/io/sentry/util/HttpUtilsTest.kt | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index 9574202179f..c6e486d14e3 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -189,6 +189,10 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { final @NotNull String cookie, final @NotNull KeyValueCollectionBehavior behavior, final @Nullable List additionalSensitiveCookieNames) { + if (cookie.trim().isEmpty()) { + return cookie; + } + if (!isValidCookiePair(cookie)) { return SENSITIVE_DATA_SUBSTITUTE; } diff --git a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt index d90ef519ad5..9d57dd59c61 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -172,6 +172,38 @@ class HttpUtilsTest { .isEqualTo(cookies) } + @Test + fun `cookie filter preserves trailing whitespace after a cookie pair`() { + assertThat( + HttpUtils.filterCookies( + "theme=dark ", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark ") + } + + @Test + fun `cookie filter preserves trailing blank cookie segments`() { + assertThat( + HttpUtils.filterCookies( + "theme=dark;", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark;") + assertThat( + HttpUtils.filterCookies( + "theme=dark; ", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark; ") + } + @Test fun `cookie filter replaces comma-separated malformed cookies in quoted values`() { assertThat( From 3315b1886145fd4f78aa96089d2217beaa9629a8 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 7 Sep 2026 07:58:23 +0200 Subject: [PATCH 53/63] ref(core): Remove broad cookie filtering catches Let unexpected implementation errors remain visible instead of swallowing fatal JVM errors during deterministic cookie parsing. Co-Authored-By: Claude --- .../main/java/io/sentry/util/HttpUtils.java | 38 ++++++++----------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index c6e486d14e3..c9c9868d097 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -148,20 +148,16 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return null; } - try { - final @NotNull String[] cookieValues = cookies.split(";", -1); - final @NotNull StringBuilder filteredCookies = new StringBuilder(); - for (int i = 0; i < cookieValues.length; i++) { - if (i > 0) { - filteredCookies.append(';'); - } - filteredCookies.append( - filterCookie(cookieValues[i], behavior, additionalSensitiveCookieNames)); + final @NotNull String[] cookieValues = cookies.split(";", -1); + final @NotNull StringBuilder filteredCookies = new StringBuilder(); + for (int i = 0; i < cookieValues.length; i++) { + if (i > 0) { + filteredCookies.append(';'); } - return filteredCookies.toString(); - } catch (Throwable ignored) { - return SENSITIVE_DATA_SUBSTITUTE; + filteredCookies.append( + filterCookie(cookieValues[i], behavior, additionalSensitiveCookieNames)); } + return filteredCookies.toString(); } public static @Nullable String filterSetCookie( @@ -170,19 +166,15 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return null; } - try { - final int attributesSeparator = cookie.indexOf(';'); - final @NotNull String cookieValue = - attributesSeparator < 0 ? cookie : cookie.substring(0, attributesSeparator); - if (!isValidCookiePair(cookieValue)) { - return SENSITIVE_DATA_SUBSTITUTE; - } - final @NotNull String attributes = - attributesSeparator < 0 ? "" : cookie.substring(attributesSeparator); - return filterCookie(cookieValue, behavior, null) + attributes; - } catch (Throwable ignored) { + final int attributesSeparator = cookie.indexOf(';'); + final @NotNull String cookieValue = + attributesSeparator < 0 ? cookie : cookie.substring(0, attributesSeparator); + if (!isValidCookiePair(cookieValue)) { return SENSITIVE_DATA_SUBSTITUTE; } + final @NotNull String attributes = + attributesSeparator < 0 ? "" : cookie.substring(attributesSeparator); + return filterCookie(cookieValue, behavior, null) + attributes; } private static @NotNull String filterCookie( From b11dcdd55857197a8162bce210bf1fc419648259 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 7 Sep 2026 09:09:24 +0200 Subject: [PATCH 54/63] ref(core): Extract cookie utilities from HTTP utilities Move cookie parsing and filtering into a focused internal utility and update integrations to use it. Keep generic query and header filtering in HttpUtils. Co-Authored-By: Claude --- .../apollo3/SentryApollo3HttpInterceptor.kt | 5 +- .../apollo4/SentryApollo4HttpInterceptor.kt | 5 +- .../ktorClient/SentryKtorClientUtils.kt | 5 +- .../io/sentry/okhttp/SentryOkHttpUtils.kt | 5 +- .../OpenTelemetryAttributesExtractor.java | 3 +- .../sentry/spring7/SentryRequestResolver.java | 9 +- .../webflux/SentryRequestResolver.java | 9 +- .../spring/jakarta/SentryRequestResolver.java | 9 +- .../webflux/SentryRequestResolver.java | 9 +- .../sentry/spring/SentryRequestResolver.java | 9 +- .../spring/webflux/SentryRequestResolver.java | 9 +- sentry/api/sentry.api | 22 +- .../main/java/io/sentry/util/CookieUtils.java | 274 +++++++++++++ .../main/java/io/sentry/util/HttpUtils.java | 275 +------------ .../java/io/sentry/util/CookieUtilsTest.kt | 381 ++++++++++++++++++ .../test/java/io/sentry/util/HttpUtilsTest.kt | 374 ----------------- 16 files changed, 717 insertions(+), 686 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/util/CookieUtils.java create mode 100644 sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index cd4724155fe..8c84630a0ae 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -27,6 +27,7 @@ import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request import io.sentry.protocol.Response +import io.sentry.util.CookieUtils import io.sentry.util.GraphqlUtils import io.sentry.util.HttpUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion @@ -273,7 +274,7 @@ constructor( private fun getRequestCookies(headers: List): String? { val cookies = getHeader("Cookie", headers) return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - HttpUtils.filterCookies(cookies, scopes.options.dataCollectionResolver.cookies, null) + CookieUtils.filterCookies(cookies, scopes.options.dataCollectionResolver.cookies, null) } else if (scopes.options.isSendDefaultPii) { cookies } else { @@ -284,7 +285,7 @@ constructor( private fun getResponseCookies(headers: List): String? { val cookies = getHeader("Set-Cookie", headers) return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + CookieUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) } else if (scopes.options.isSendDefaultPii) { cookies } else { diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index cab278f5b37..a2e93fca7d1 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -25,6 +25,7 @@ import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Mechanism import io.sentry.protocol.Request import io.sentry.protocol.Response +import io.sentry.util.CookieUtils import io.sentry.util.GraphqlUtils import io.sentry.util.HttpUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion @@ -272,7 +273,7 @@ constructor( private fun getRequestCookies(headers: List): String? { val cookies = getHeader("Cookie", headers) return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - HttpUtils.filterCookies(cookies, scopes.options.dataCollectionResolver.cookies, null) + CookieUtils.filterCookies(cookies, scopes.options.dataCollectionResolver.cookies, null) } else if (scopes.options.isSendDefaultPii) { cookies } else { @@ -283,7 +284,7 @@ constructor( private fun getResponseCookies(headers: List): String? { val cookies = getHeader("Set-Cookie", headers) return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + CookieUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) } else if (scopes.options.isSendDefaultPii) { cookies } else { diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt index 40af65b8494..257027329c3 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt @@ -16,6 +16,7 @@ import io.sentry.TypeCheckHint import io.sentry.exception.ExceptionMechanismException import io.sentry.exception.SentryHttpClientException import io.sentry.protocol.Mechanism +import io.sentry.util.CookieUtils import io.sentry.util.HttpUtils import io.sentry.util.UrlUtils @@ -67,7 +68,7 @@ internal object SentryKtorClientUtils { private fun getRequestCookies(scopes: IScopes, cookies: String?): String? = if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - HttpUtils.filterCookies( + CookieUtils.filterCookies( cookies, scopes.options.dataCollectionResolver.cookies, null, @@ -80,7 +81,7 @@ internal object SentryKtorClientUtils { private fun getResponseCookies(scopes: IScopes, cookies: String?): String? = if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + CookieUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) } else if (scopes.options.isSendDefaultPii) { cookies } else { diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt index 9299e236604..0207880edff 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt @@ -7,6 +7,7 @@ import io.sentry.TypeCheckHint import io.sentry.exception.ExceptionMechanismException import io.sentry.exception.SentryHttpClientException import io.sentry.protocol.Mechanism +import io.sentry.util.CookieUtils import io.sentry.util.HttpUtils import io.sentry.util.UrlUtils import okhttp3.Headers @@ -67,7 +68,7 @@ internal object SentryOkHttpUtils { private fun getRequestCookies(scopes: IScopes, cookies: String?): String? = if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - HttpUtils.filterCookies( + CookieUtils.filterCookies( cookies, scopes.options.dataCollectionResolver.cookies, null, @@ -80,7 +81,7 @@ internal object SentryOkHttpUtils { private fun getResponseCookies(scopes: IScopes, cookies: String?): String? = if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - HttpUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) + CookieUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) } else if (scopes.options.isSendDefaultPii) { cookies } else { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java index 87088ae2377..8612f1132f8 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java @@ -9,6 +9,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.protocol.Request; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.StringUtils; import io.sentry.util.UrlUtils; @@ -91,7 +92,7 @@ private static Map collectHeaders( headers.put( headerName, toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( headerValues, headerName, null))); } catch (Throwable t) { options diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java index c66362fe4ed..c3d96eb8115 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java @@ -6,6 +6,7 @@ import io.sentry.SentryLevel; import io.sentry.protocol.Request; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -48,18 +49,18 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - final @NotNull String cookieName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String cookieName = CookieUtils.COOKIE_HEADER_NAME; if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { sentryRequest.setCookies( toString( - HttpUtils.filterCookiesFromHeader( + CookieUtils.filterCookiesFromHeader( httpRequest.getHeaders(cookieName), scopes.getOptions().getDataCollectionResolver().getCookies(), additionalSecurityCookieNames))); } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; @@ -75,7 +76,7 @@ Map resolveHeadersMap( || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( request.getHeaders(headerName), headerName, additionalSecurityCookieNames); headersMap.put(headerName, toString(filteredHeaders)); } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java index 0d785680748..3a4366d481b 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java @@ -3,6 +3,7 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.IScopes; import io.sentry.protocol.Request; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -37,18 +38,18 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - final @NotNull String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = CookieUtils.COOKIE_HEADER_NAME; if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { sentryRequest.setCookies( toString( - HttpUtils.filterCookiesFromHeader( + CookieUtils.filterCookiesFromHeader( httpRequest.getHeaders().get(headerName), scopes.getOptions().getDataCollectionResolver().getCookies(), Collections.emptyList()))); } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( httpRequest.getHeaders().get(headerName), headerName, Collections.emptyList()))); } return sentryRequest; @@ -65,7 +66,7 @@ Map resolveHeadersMap(final HttpHeaders request) { headersMap.put( headerName, toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( entry.getValue(), headerName, Collections.emptyList()))); } } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java index 94316e3ed43..d4f69cb8714 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryRequestResolver.java @@ -6,6 +6,7 @@ import io.sentry.SentryLevel; import io.sentry.protocol.Request; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -48,18 +49,18 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - final @NotNull String cookieName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String cookieName = CookieUtils.COOKIE_HEADER_NAME; if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { sentryRequest.setCookies( toString( - HttpUtils.filterCookiesFromHeader( + CookieUtils.filterCookiesFromHeader( httpRequest.getHeaders(cookieName), scopes.getOptions().getDataCollectionResolver().getCookies(), additionalSecurityCookieNames))); } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; @@ -75,7 +76,7 @@ Map resolveHeadersMap( || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( request.getHeaders(headerName), headerName, additionalSecurityCookieNames); headersMap.put(headerName, toString(filteredHeaders)); } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java index 6383e02fbdc..774de4d6b31 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/SentryRequestResolver.java @@ -3,6 +3,7 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.IScopes; import io.sentry.protocol.Request; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -37,18 +38,18 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - final @NotNull String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = CookieUtils.COOKIE_HEADER_NAME; if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { sentryRequest.setCookies( toString( - HttpUtils.filterCookiesFromHeader( + CookieUtils.filterCookiesFromHeader( httpRequest.getHeaders().get(headerName), scopes.getOptions().getDataCollectionResolver().getCookies(), Collections.emptyList()))); } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( httpRequest.getHeaders().get(headerName), headerName, Collections.emptyList()))); } return sentryRequest; @@ -65,7 +66,7 @@ Map resolveHeadersMap(final HttpHeaders request) { headersMap.put( headerName, toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( entry.getValue(), headerName, Collections.emptyList()))); } } diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java index dcbc697c160..607fb2b58be 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestResolver.java @@ -6,6 +6,7 @@ import io.sentry.SentryLevel; import io.sentry.protocol.Request; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -48,18 +49,18 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { extractSecurityCookieNamesOrUseCached(httpRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); - final @NotNull String cookieName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String cookieName = CookieUtils.COOKIE_HEADER_NAME; if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { sentryRequest.setCookies( toString( - HttpUtils.filterCookiesFromHeader( + CookieUtils.filterCookiesFromHeader( httpRequest.getHeaders(cookieName), scopes.getOptions().getDataCollectionResolver().getCookies(), additionalSecurityCookieNames))); } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames))); } return sentryRequest; @@ -75,7 +76,7 @@ Map resolveHeadersMap( || scopes.getOptions().isSendDefaultPii() || !HttpUtils.containsSensitiveHeader(headerName)) { final @Nullable List filteredHeaders = - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( request.getHeaders(headerName), headerName, additionalSecurityCookieNames); headersMap.put(headerName, toString(filteredHeaders)); } diff --git a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java index 27c1a754be5..76ad3ba1703 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryRequestResolver.java @@ -3,6 +3,7 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.IScopes; import io.sentry.protocol.Request; +import io.sentry.util.CookieUtils; import io.sentry.util.HttpUtils; import io.sentry.util.Objects; import io.sentry.util.UrlUtils; @@ -37,18 +38,18 @@ public SentryRequestResolver(final @NotNull IScopes scopes) { urlDetails.applyToRequest(sentryRequest); sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); - final @NotNull String headerName = HttpUtils.COOKIE_HEADER_NAME; + final @NotNull String headerName = CookieUtils.COOKIE_HEADER_NAME; if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { sentryRequest.setCookies( toString( - HttpUtils.filterCookiesFromHeader( + CookieUtils.filterCookiesFromHeader( httpRequest.getHeaders().get(headerName), scopes.getOptions().getDataCollectionResolver().getCookies(), Collections.emptyList()))); } else if (scopes.getOptions().isSendDefaultPii()) { sentryRequest.setCookies( toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( httpRequest.getHeaders().get(headerName), headerName, Collections.emptyList()))); } return sentryRequest; @@ -65,7 +66,7 @@ Map resolveHeadersMap(final HttpHeaders request) { headersMap.put( headerName, toString( - HttpUtils.filterOutSecurityCookiesFromHeader( + CookieUtils.filterOutSecurityCookiesFromHeader( entry.getValue(), headerName, Collections.emptyList()))); } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d446fc4c17b..51c84ff5662 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7732,6 +7732,19 @@ public abstract interface class io/sentry/util/CollectionUtils$Predicate { public abstract fun test (Ljava/lang/Object;)Z } +public final class io/sentry/util/CookieUtils { + public static final field COOKIE_HEADER_NAME Ljava/lang/String; + public fun ()V + public static fun filterCookies (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/lang/String; + public static fun filterCookiesFromHeader (Ljava/util/Enumeration;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; + public static fun filterCookiesFromHeader (Ljava/util/List;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; + public static fun filterOutSecurityCookies (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String; + public static fun filterOutSecurityCookiesFromHeader (Ljava/util/Enumeration;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; + public static fun filterOutSecurityCookiesFromHeader (Ljava/util/List;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; + public static fun filterSetCookie (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;)Ljava/lang/String; + public static fun isSecurityCookie (Ljava/lang/String;Ljava/util/List;)Z +} + public final class io/sentry/util/DebugMetaPropertiesApplier { public static field DEBUG_META_PROPERTIES_FILENAME Ljava/lang/String; public fun ()V @@ -7802,21 +7815,12 @@ public abstract interface class io/sentry/util/HintUtils$SentryNullableConsumer } public final class io/sentry/util/HttpUtils { - public static final field COOKIE_HEADER_NAME Ljava/lang/String; public fun ()V public static fun containsSensitiveHeader (Ljava/lang/String;)Z - public static fun filterCookies (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/lang/String; - public static fun filterCookiesFromHeader (Ljava/util/Enumeration;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; - public static fun filterCookiesFromHeader (Ljava/util/List;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; public static fun filterHeaders (Ljava/util/Map;Lio/sentry/KeyValueCollectionBehavior;)Ljava/util/Map; - public static fun filterOutSecurityCookies (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String; - public static fun filterOutSecurityCookiesFromHeader (Ljava/util/Enumeration;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; - public static fun filterOutSecurityCookiesFromHeader (Ljava/util/List;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; public static fun filterQueryParams (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;)Ljava/lang/String; - public static fun filterSetCookie (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;)Ljava/lang/String; public static fun isHttpClientError (I)Z public static fun isHttpServerError (I)Z - public static fun isSecurityCookie (Ljava/lang/String;Ljava/util/List;)Z } public final class io/sentry/util/InitUtil { diff --git a/sentry/src/main/java/io/sentry/util/CookieUtils.java b/sentry/src/main/java/io/sentry/util/CookieUtils.java new file mode 100644 index 00000000000..203eee291a9 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/CookieUtils.java @@ -0,0 +1,274 @@ +package io.sentry.util; + +import static io.sentry.util.UrlUtils.SENSITIVE_DATA_SUBSTITUTE; + +import io.sentry.KeyValueCollectionBehavior; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Locale; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class CookieUtils { + + public static final String COOKIE_HEADER_NAME = "Cookie"; + + private static final List SECURITY_COOKIES = + Arrays.asList( + "JSESSIONID", + "JSESSIONIDSSO", + "JSSOSESSIONID", + "SESSIONID", + "SID", + "CSRFTOKEN", + "XSRF-TOKEN"); + + public static @Nullable List filterCookiesFromHeader( + final @Nullable Enumeration headers, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + return headers == null + ? null + : filterCookiesFromHeader( + Collections.list(headers), behavior, additionalSensitiveCookieNames); + } + + public static @Nullable List filterCookiesFromHeader( + final @Nullable List headers, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + if (headers == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + final @NotNull List filteredHeaders = new ArrayList<>(); + for (final String header : headers) { + final @Nullable String filteredHeader = + filterCookies(header, behavior, additionalSensitiveCookieNames); + if (filteredHeader != null) { + filteredHeaders.add(filteredHeader); + } + } + return filteredHeaders; + } + + public static @Nullable String filterCookies( + final @Nullable String cookies, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + if (cookies == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + final @NotNull String[] cookieValues = cookies.split(";", -1); + final @NotNull StringBuilder filteredCookies = new StringBuilder(); + for (int i = 0; i < cookieValues.length; i++) { + if (i > 0) { + filteredCookies.append(';'); + } + filteredCookies.append( + filterCookie(cookieValues[i], behavior, additionalSensitiveCookieNames)); + } + return filteredCookies.toString(); + } + + public static @Nullable String filterSetCookie( + final @Nullable String cookie, final @NotNull KeyValueCollectionBehavior behavior) { + if (cookie == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { + return null; + } + + final int attributesSeparator = cookie.indexOf(';'); + final @NotNull String cookieValue = + attributesSeparator < 0 ? cookie : cookie.substring(0, attributesSeparator); + if (!isValidCookiePair(cookieValue)) { + return SENSITIVE_DATA_SUBSTITUTE; + } + final @NotNull String attributes = + attributesSeparator < 0 ? "" : cookie.substring(attributesSeparator); + return filterCookie(cookieValue, behavior, null) + attributes; + } + + private static @NotNull String filterCookie( + final @NotNull String cookie, + final @NotNull KeyValueCollectionBehavior behavior, + final @Nullable List additionalSensitiveCookieNames) { + if (cookie.trim().isEmpty()) { + return cookie; + } + + if (!isValidCookiePair(cookie)) { + return SENSITIVE_DATA_SUBSTITUTE; + } + + final int separator = cookie.indexOf('='); + final @NotNull String name = cookie.substring(0, separator); + final @NotNull String normalizedName = name.trim(); + final boolean sensitive = + HttpUtils.containsSensitiveDataKey(normalizedName) + || isSecurityCookie(normalizedName, additionalSensitiveCookieNames); + final boolean matchesTerm = HttpUtils.containsTerm(normalizedName, behavior.getTerms()); + final boolean shouldFilter = + sensitive + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST && matchesTerm) + || (behavior.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST && !matchesTerm); + + if (shouldFilter) { + return name + "=" + SENSITIVE_DATA_SUBSTITUTE; + } + return cookie; + } + + private static boolean isValidCookiePair(final @NotNull String cookie) { + final @NotNull String cookiePair = cookie.trim(); + final int separator = cookiePair.indexOf('='); + if (separator <= 0 || !isValidCookieName(cookiePair.substring(0, separator))) { + return false; + } + + final @NotNull String value = cookiePair.substring(separator + 1); + int start = 0; + int end = value.length(); + if (!value.isEmpty() && value.charAt(0) == '"') { + if (value.length() < 2 || value.charAt(value.length() - 1) != '"') { + return false; + } + start++; + end--; + } + + for (int i = start; i < end; i++) { + if (!isCookieOctet(value.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isValidCookieName(final @NotNull String name) { + for (int i = 0; i < name.length(); i++) { + if (!isCookieNameCharacter(name.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isCookieNameCharacter(final char value) { + if ((value >= 'a' && value <= 'z') + || (value >= 'A' && value <= 'Z') + || (value >= '0' && value <= '9')) { + return true; + } + + switch (value) { + case '!': + case '#': + case '$': + case '%': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + default: + return false; + } + } + + private static boolean isCookieOctet(final char value) { + return value == 0x21 + || (value >= 0x23 && value <= 0x2B) + || (value >= 0x2D && value <= 0x3A) + || (value >= 0x3C && value <= 0x5B) + || (value >= 0x5D && value <= 0x7E); + } + + public static @Nullable List filterOutSecurityCookiesFromHeader( + final @Nullable Enumeration headers, + final @Nullable String headerName, + final @Nullable List additionalCookieNamesToFilter) { + if (headers == null) { + return null; + } + + return filterOutSecurityCookiesFromHeader( + Collections.list(headers), headerName, additionalCookieNamesToFilter); + } + + public static @Nullable List filterOutSecurityCookiesFromHeader( + final @Nullable List headers, + final @Nullable String headerName, + final @Nullable List additionalCookieNamesToFilter) { + if (headers == null) { + return null; + } + + if (headerName != null && !COOKIE_HEADER_NAME.equalsIgnoreCase(headerName)) { + return headers; + } + + final @NotNull ArrayList filteredHeaders = new ArrayList<>(); + for (final String header : headers) { + filteredHeaders.add(filterOutSecurityCookies(header, additionalCookieNamesToFilter)); + } + return filteredHeaders; + } + + public static @Nullable String filterOutSecurityCookies( + final @Nullable String cookieString, + final @Nullable List additionalCookieNamesToFilter) { + if (cookieString == null) { + return null; + } + + final @NotNull String[] cookies = cookieString.split(";", -1); + final @NotNull StringBuilder filteredCookieString = new StringBuilder(); + boolean isFirst = true; + for (String cookie : cookies) { + if (!isFirst) { + filteredCookieString.append(";"); + } + + final @NotNull String[] cookieParts = cookie.split("=", -1); + final @NotNull String cookieName = cookieParts[0]; + if (isSecurityCookie(cookieName.trim(), additionalCookieNamesToFilter)) { + filteredCookieString.append(cookieName + "=" + SENSITIVE_DATA_SUBSTITUTE); + } else { + filteredCookieString.append(cookie); + } + isFirst = false; + } + return filteredCookieString.toString(); + } + + public static boolean isSecurityCookie( + final @NotNull String cookieName, + final @Nullable List additionalCookieNamesToFilter) { + final @NotNull String cookieNameToSearchFor = cookieName.toUpperCase(Locale.ROOT); + if (SECURITY_COOKIES.contains(cookieNameToSearchFor)) { + return true; + } + + if (additionalCookieNamesToFilter != null) { + for (String additionalCookieName : additionalCookieNamesToFilter) { + if (additionalCookieName.toUpperCase(Locale.ROOT).equals(cookieNameToSearchFor)) { + return true; + } + } + } + return false; + } +} diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index c9c9868d097..faecd8b765a 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -5,10 +5,7 @@ import io.sentry.HttpStatusCodeRange; import io.sentry.KeyValueCollectionBehavior; import java.net.URLDecoder; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.Enumeration; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -20,8 +17,6 @@ @ApiStatus.Internal public final class HttpUtils { - public static final String COOKIE_HEADER_NAME = "Cookie"; - private static final List SENSITIVE_HEADERS = Arrays.asList( "X-FORWARDED-FOR", @@ -57,16 +52,6 @@ public final class HttpUtils { "sid", "identity"); - private static final List SECURITY_COOKIES = - Arrays.asList( - "JSESSIONID", - "JSESSIONIDSSO", - "JSSOSESSIONID", - "SESSIONID", - "SID", - "CSRFTOKEN", - "XSRF-TOKEN"); - private static final HttpStatusCodeRange CLIENT_ERROR_STATUS_CODES = new HttpStatusCodeRange(400, 499); @@ -111,174 +96,6 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { return filteredQuery.toString(); } - public static @Nullable List filterCookiesFromHeader( - final @Nullable Enumeration headers, - final @NotNull KeyValueCollectionBehavior behavior, - final @Nullable List additionalSensitiveCookieNames) { - return headers == null - ? null - : filterCookiesFromHeader( - Collections.list(headers), behavior, additionalSensitiveCookieNames); - } - - public static @Nullable List filterCookiesFromHeader( - final @Nullable List headers, - final @NotNull KeyValueCollectionBehavior behavior, - final @Nullable List additionalSensitiveCookieNames) { - if (headers == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { - return null; - } - - final @NotNull List filteredHeaders = new ArrayList<>(); - for (final String header : headers) { - final @Nullable String filteredHeader = - filterCookies(header, behavior, additionalSensitiveCookieNames); - if (filteredHeader != null) { - filteredHeaders.add(filteredHeader); - } - } - return filteredHeaders; - } - - public static @Nullable String filterCookies( - final @Nullable String cookies, - final @NotNull KeyValueCollectionBehavior behavior, - final @Nullable List additionalSensitiveCookieNames) { - if (cookies == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { - return null; - } - - final @NotNull String[] cookieValues = cookies.split(";", -1); - final @NotNull StringBuilder filteredCookies = new StringBuilder(); - for (int i = 0; i < cookieValues.length; i++) { - if (i > 0) { - filteredCookies.append(';'); - } - filteredCookies.append( - filterCookie(cookieValues[i], behavior, additionalSensitiveCookieNames)); - } - return filteredCookies.toString(); - } - - public static @Nullable String filterSetCookie( - final @Nullable String cookie, final @NotNull KeyValueCollectionBehavior behavior) { - if (cookie == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { - return null; - } - - final int attributesSeparator = cookie.indexOf(';'); - final @NotNull String cookieValue = - attributesSeparator < 0 ? cookie : cookie.substring(0, attributesSeparator); - if (!isValidCookiePair(cookieValue)) { - return SENSITIVE_DATA_SUBSTITUTE; - } - final @NotNull String attributes = - attributesSeparator < 0 ? "" : cookie.substring(attributesSeparator); - return filterCookie(cookieValue, behavior, null) + attributes; - } - - private static @NotNull String filterCookie( - final @NotNull String cookie, - final @NotNull KeyValueCollectionBehavior behavior, - final @Nullable List additionalSensitiveCookieNames) { - if (cookie.trim().isEmpty()) { - return cookie; - } - - if (!isValidCookiePair(cookie)) { - return SENSITIVE_DATA_SUBSTITUTE; - } - - final int separator = cookie.indexOf('='); - final @NotNull String name = cookie.substring(0, separator); - final @NotNull String normalizedName = name.trim(); - final boolean sensitive = - containsTerm(normalizedName, SENSITIVE_DATA_KEYS) - || isSecurityCookie(normalizedName, additionalSensitiveCookieNames); - final boolean matchesTerm = containsTerm(normalizedName, behavior.getTerms()); - final boolean shouldFilter = - sensitive - || (behavior.getMode() == KeyValueCollectionBehavior.Mode.DENY_LIST && matchesTerm) - || (behavior.getMode() == KeyValueCollectionBehavior.Mode.ALLOW_LIST && !matchesTerm); - - if (shouldFilter) { - return name + "=" + SENSITIVE_DATA_SUBSTITUTE; - } - return cookie; - } - - private static boolean isValidCookiePair(final @NotNull String cookie) { - final @NotNull String cookiePair = cookie.trim(); - final int separator = cookiePair.indexOf('='); - if (separator <= 0 || !isValidCookieName(cookiePair.substring(0, separator))) { - return false; - } - - final @NotNull String value = cookiePair.substring(separator + 1); - int start = 0; - int end = value.length(); - if (!value.isEmpty() && value.charAt(0) == '"') { - if (value.length() < 2 || value.charAt(value.length() - 1) != '"') { - return false; - } - start++; - end--; - } - - for (int i = start; i < end; i++) { - if (!isCookieOctet(value.charAt(i))) { - return false; - } - } - return true; - } - - private static boolean isValidCookieName(final @NotNull String name) { - for (int i = 0; i < name.length(); i++) { - if (!isCookieNameCharacter(name.charAt(i))) { - return false; - } - } - return true; - } - - private static boolean isCookieNameCharacter(final char value) { - if ((value >= 'a' && value <= 'z') - || (value >= 'A' && value <= 'Z') - || (value >= '0' && value <= '9')) { - return true; - } - - switch (value) { - case '!': - case '#': - case '$': - case '%': - case '&': - case '\'': - case '*': - case '+': - case '-': - case '.': - case '^': - case '_': - case '`': - case '|': - case '~': - return true; - default: - return false; - } - } - - private static boolean isCookieOctet(final char value) { - return value == 0x21 - || (value >= 0x23 && value <= 0x2B) - || (value >= 0x2D && value <= 0x3A) - || (value >= 0x3C && value <= 0x5B) - || (value >= 0x5D && value <= 0x7E); - } - public static @NotNull Map filterHeaders( final @NotNull Map headers, final @NotNull KeyValueCollectionBehavior behavior) { @@ -315,8 +132,11 @@ private static boolean isCookieOctet(final char value) { } } - private static boolean containsTerm( - final @NotNull String key, final @NotNull List terms) { + static boolean containsSensitiveDataKey(final @NotNull String key) { + return containsTerm(key, SENSITIVE_DATA_KEYS); + } + + static boolean containsTerm(final @NotNull String key, final @NotNull List terms) { final @NotNull String normalizedKey = key.toLowerCase(Locale.ROOT); for (final String term : terms) { if (term != null @@ -328,91 +148,6 @@ private static boolean containsTerm( return false; } - public static @Nullable List filterOutSecurityCookiesFromHeader( - final @Nullable Enumeration headers, - final @Nullable String headerName, - final @Nullable List additionalCookieNamesToFilter) { - if (headers == null) { - return null; - } - - return filterOutSecurityCookiesFromHeader( - Collections.list(headers), headerName, additionalCookieNamesToFilter); - } - - public static @Nullable List filterOutSecurityCookiesFromHeader( - final @Nullable List headers, - final @Nullable String headerName, - final @Nullable List additionalCookieNamesToFilter) { - if (headers == null) { - return null; - } - - if (headerName != null && !"Cookie".equalsIgnoreCase(headerName)) { - return headers; - } - - final @NotNull ArrayList filteredHeaders = new ArrayList<>(); - - for (final String header : headers) { - filteredHeaders.add( - HttpUtils.filterOutSecurityCookies(header, additionalCookieNamesToFilter)); - } - - return filteredHeaders; - } - - public static @Nullable String filterOutSecurityCookies( - final @Nullable String cookieString, - final @Nullable List additionalCookieNamesToFilter) { - if (cookieString == null) { - return null; - } - try { - final @NotNull String[] cookies = cookieString.split(";", -1); - final @NotNull StringBuilder filteredCookieString = new StringBuilder(); - boolean isFirst = true; - - for (String cookie : cookies) { - if (!isFirst) { - filteredCookieString.append(";"); - } - - final @NotNull String[] cookieParts = cookie.split("=", -1); - final @NotNull String cookieName = cookieParts[0]; - if (isSecurityCookie(cookieName.trim(), additionalCookieNamesToFilter)) { - filteredCookieString.append(cookieName + "=" + SENSITIVE_DATA_SUBSTITUTE); - } else { - filteredCookieString.append(cookie); - } - isFirst = false; - } - - return filteredCookieString.toString(); - } catch (Throwable t) { - return null; - } - } - - public static boolean isSecurityCookie( - final @NotNull String cookieName, - final @Nullable List additionalCookieNamesToFilter) { - final @NotNull String cookieNameToSearchFor = cookieName.toUpperCase(Locale.ROOT); - if (SECURITY_COOKIES.contains(cookieNameToSearchFor)) { - return true; - } - - if (additionalCookieNamesToFilter != null) { - for (String additionalCookieName : additionalCookieNamesToFilter) { - if (additionalCookieName.toUpperCase(Locale.ROOT).equals(cookieNameToSearchFor)) { - return true; - } - } - } - - return false; - } - public static boolean isHttpClientError(final int statusCode) { return CLIENT_ERROR_STATUS_CODES.isInRange(statusCode); } diff --git a/sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt b/sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt new file mode 100644 index 00000000000..bd0fa46d236 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt @@ -0,0 +1,381 @@ +package io.sentry.util + +import com.google.common.truth.Truth.assertThat +import io.sentry.KeyValueCollectionBehavior +import java.util.Enumeration +import java.util.StringTokenizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class CookieUtilsTest { + @Test + fun `cookie filter disables collection in off mode`() { + assertThat( + CookieUtils.filterCookies( + "name=value", + KeyValueCollectionBehavior.off(), + emptyList(), + ) + ) + .isNull() + } + + @Test + fun `cookie deny list filters built-in configured and integration sensitive names`() { + assertThat( + CookieUtils.filterCookies( + "name=value; sessionId=secret; customerId=123; frameworkSession=456", + KeyValueCollectionBehavior.denyList("customer"), + listOf("frameworkSession"), + ) + ) + .isEqualTo( + "name=value; sessionId=[Filtered]; customerId=[Filtered]; frameworkSession=[Filtered]" + ) + } + + @Test + fun `cookie allow list only retains allowed non-sensitive values`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark; sessionId=secret; language=en", + KeyValueCollectionBehavior.allowList("theme", "session"), + emptyList(), + ) + ) + .isEqualTo("theme=dark; sessionId=[Filtered]; language=[Filtered]") + } + + @Test + fun `cookie filter preserves empty and padded base64 values`() { + assertThat( + CookieUtils.filterCookies( + "empty=; data=YWJjZA==", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("empty=; data=YWJjZA==") + } + + @Test + fun `cookie filter uses only the first equals separator`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark=contrast; token=abc=123", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark=contrast; token=[Filtered]") + } + + @Test + fun `cookie filter replaces malformed pairs without discarding valid pairs`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark; opaque; =secret; empty=; sessionId=secret", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark;[Filtered];[Filtered]; empty=; sessionId=[Filtered]") + } + + @Test + fun `cookie filter replaces comma-separated malformed cookies`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark, sessionId=secret", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie filter replaces space-separated malformed cookies`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark sessionId=secret", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie filter preserves valid names and values`() { + val cookies = + "plain=abc123; empty=; base64=YWJjZA==; quoted=\"dark\"; quoted-empty=\"\"; encoded=hello%2Fworld; !#\$%&'*+-.^_`|~=!#\$%&'()*+-./:<=>?@[]^_`{|}~" + + assertThat( + CookieUtils.filterCookies( + cookies, + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo(cookies) + } + + @Test + fun `cookie filter preserves trailing whitespace after a cookie pair`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark ", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark ") + } + + @Test + fun `cookie filter preserves trailing blank cookie segments`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark;", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark;") + assertThat( + CookieUtils.filterCookies( + "theme=dark; ", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("theme=dark; ") + } + + @Test + fun `cookie filter replaces comma-separated malformed cookies in quoted values`() { + assertThat( + CookieUtils.filterCookies( + "theme=\"dark, sessionId=secret\"", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie filter replaces space-separated malformed cookies in quoted values`() { + assertThat( + CookieUtils.filterCookies( + "theme=\"dark sessionId=secret\"", + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `cookie allow list never exposes malformed pairs`() { + assertThat( + CookieUtils.filterCookies( + "theme=dark; opaque; =secret", + KeyValueCollectionBehavior.allowList("theme", "opaque"), + emptyList(), + ) + ) + .isEqualTo("theme=dark;[Filtered];[Filtered]") + } + + @Test + fun `set cookie filter preserves attributes`() { + assertThat( + CookieUtils.filterSetCookie( + "sessionId=secret; Path=/; HttpOnly; SameSite=Lax", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("sessionId=[Filtered]; Path=/; HttpOnly; SameSite=Lax") + } + + @Test + fun `set cookie filter preserves empty and padded base64 values`() { + assertThat( + CookieUtils.filterSetCookie( + "data=YWJjZA==; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=3600; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo( + "data=YWJjZA==; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=3600; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax" + ) + assertThat( + CookieUtils.filterSetCookie( + "empty=; Path=/", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("empty=; Path=/") + } + + @Test + fun `set cookie allow list retains allowed non-sensitive value and attributes`() { + assertThat( + CookieUtils.filterSetCookie( + "theme=dark; Path=/; Secure", + KeyValueCollectionBehavior.allowList("theme"), + ) + ) + .isEqualTo("theme=dark; Path=/; Secure") + } + + @Test + fun `set cookie filter replaces malformed cookie pair and discards attributes`() { + assertThat( + CookieUtils.filterSetCookie( + "opaque; Path=/; HttpOnly", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("[Filtered]") + assertThat( + CookieUtils.filterSetCookie( + "=secret; Path=/; HttpOnly", + KeyValueCollectionBehavior.denyList(), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `set cookie allow list never exposes malformed cookie pair`() { + assertThat( + CookieUtils.filterSetCookie( + "opaque; Path=/; HttpOnly", + KeyValueCollectionBehavior.allowList("opaque"), + ) + ) + .isEqualTo("[Filtered]") + } + + @Test + fun `set cookie filter disables collection in off mode`() { + assertThat( + CookieUtils.filterSetCookie( + "theme=dark; Path=/", + KeyValueCollectionBehavior.off(), + ) + ) + .isNull() + } + + @Test + fun `cookie header filter processes every header value`() { + assertThat( + CookieUtils.filterCookiesFromHeader( + listOf("theme=dark; SID=secret", "language=en"), + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .containsExactly("theme=dark; SID=[Filtered]", "language=en") + .inOrder() + } + + @Test + fun `cookie header filter skips null header values`() { + assertThat( + CookieUtils.filterCookiesFromHeader( + java.util.Arrays.asList("theme=dark", null), + KeyValueCollectionBehavior.denyList(), + emptyList(), + ) + ) + .containsExactly("theme=dark") + } + + @Test + fun `null enumeration returns null when filtering security cookies from headers`() { + val enumeration: Enumeration? = null + val headers = CookieUtils.filterOutSecurityCookiesFromHeader(enumeration, "Cookie", emptyList()) + + assertNull(headers) + } + + @Test + fun `null list returns null when filtering security cookies from headers`() { + val list: List? = null + val headers = CookieUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", emptyList()) + + assertNull(headers) + } + + @Test + fun `enumeration works when filtering security cookies from headers`() { + val enumeration: Enumeration? = + StringTokenizer( + "Cookie_2=value2; Cookie_3=value3; JSESSIONID=123456789; mysessioncookiename=1F54D793F432FEE4CFC6A3FAED6D062F|Cookie_1=value1; SID=987654312", + "|", + ) + as Enumeration + val headers = + CookieUtils.filterOutSecurityCookiesFromHeader( + enumeration, + "Cookie", + listOf("mysessioncookiename"), + ) + + assertNotNull(headers) + assertEquals(2, headers.size) + assertEquals( + "Cookie_2=value2; Cookie_3=value3; JSESSIONID=[Filtered]; mysessioncookiename=[Filtered]", + headers!![0], + ) + assertEquals("Cookie_1=value1; SID=[Filtered]", headers!![1]) + } + + @Test + fun `list works when filtering security cookies from headers`() { + val list: List? = + listOf( + "Cookie_2=value2; Cookie_3=value3; JSESSIONID=123456789; mysessioncookiename=1F54D793F432FEE4CFC6A3FAED6D062F", + "Cookie_1=value1; SID=987654312", + ) + val headers = + CookieUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) + + assertNotNull(headers) + assertEquals(2, headers.size) + assertEquals( + "Cookie_2=value2; Cookie_3=value3; JSESSIONID=[Filtered]; mysessioncookiename=[Filtered]", + headers!![0], + ) + assertEquals("Cookie_1=value1; SID=[Filtered]", headers!![1]) + } + + @Test + fun `filtering security cookies from header works for corrupted string`() { + val list: List? = listOf("Cookie_1=value1;; SID=; JSESSIONID; =") + val headers = + CookieUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) + + assertNotNull(headers) + assertEquals(1, headers.size) + assertEquals("Cookie_1=value1;; SID=[Filtered]; JSESSIONID=[Filtered]; =", headers!![0]) + } + + @Test + fun `filtering security cookies from header works for null string`() { + val list: List? = listOf(null) + val headers = + CookieUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) + + assertNotNull(headers) + assertEquals(1, headers.size) + assertEquals(null, headers!![0]) + } +} diff --git a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt index 9d57dd59c61..624f94a2ef0 100644 --- a/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HttpUtilsTest.kt @@ -2,12 +2,7 @@ package io.sentry.util import com.google.common.truth.Truth.assertThat import io.sentry.KeyValueCollectionBehavior -import java.util.Enumeration -import java.util.StringTokenizer import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull class HttpUtilsTest { @Test @@ -59,294 +54,6 @@ class HttpUtilsTest { .isEqualTo("name=&flag&&token=[Filtered]") } - @Test - fun `cookie filter disables collection in off mode`() { - assertThat( - HttpUtils.filterCookies( - "name=value", - KeyValueCollectionBehavior.off(), - emptyList(), - ) - ) - .isNull() - } - - @Test - fun `cookie deny list filters built-in configured and integration sensitive names`() { - assertThat( - HttpUtils.filterCookies( - "name=value; sessionId=secret; customerId=123; frameworkSession=456", - KeyValueCollectionBehavior.denyList("customer"), - listOf("frameworkSession"), - ) - ) - .isEqualTo( - "name=value; sessionId=[Filtered]; customerId=[Filtered]; frameworkSession=[Filtered]" - ) - } - - @Test - fun `cookie allow list only retains allowed non-sensitive values`() { - assertThat( - HttpUtils.filterCookies( - "theme=dark; sessionId=secret; language=en", - KeyValueCollectionBehavior.allowList("theme", "session"), - emptyList(), - ) - ) - .isEqualTo("theme=dark; sessionId=[Filtered]; language=[Filtered]") - } - - @Test - fun `cookie filter preserves empty and padded base64 values`() { - assertThat( - HttpUtils.filterCookies( - "empty=; data=YWJjZA==", - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo("empty=; data=YWJjZA==") - } - - @Test - fun `cookie filter uses only the first equals separator`() { - assertThat( - HttpUtils.filterCookies( - "theme=dark=contrast; token=abc=123", - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo("theme=dark=contrast; token=[Filtered]") - } - - @Test - fun `cookie filter replaces malformed pairs without discarding valid pairs`() { - assertThat( - HttpUtils.filterCookies( - "theme=dark; opaque; =secret; empty=; sessionId=secret", - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo("theme=dark;[Filtered];[Filtered]; empty=; sessionId=[Filtered]") - } - - @Test - fun `cookie filter replaces comma-separated malformed cookies`() { - assertThat( - HttpUtils.filterCookies( - "theme=dark, sessionId=secret", - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo("[Filtered]") - } - - @Test - fun `cookie filter replaces space-separated malformed cookies`() { - assertThat( - HttpUtils.filterCookies( - "theme=dark sessionId=secret", - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo("[Filtered]") - } - - @Test - fun `cookie filter preserves valid names and values`() { - val cookies = - "plain=abc123; empty=; base64=YWJjZA==; quoted=\"dark\"; quoted-empty=\"\"; encoded=hello%2Fworld; !#\$%&'*+-.^_`|~=!#\$%&'()*+-./:<=>?@[]^_`{|}~" - - assertThat( - HttpUtils.filterCookies( - cookies, - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo(cookies) - } - - @Test - fun `cookie filter preserves trailing whitespace after a cookie pair`() { - assertThat( - HttpUtils.filterCookies( - "theme=dark ", - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo("theme=dark ") - } - - @Test - fun `cookie filter preserves trailing blank cookie segments`() { - assertThat( - HttpUtils.filterCookies( - "theme=dark;", - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo("theme=dark;") - assertThat( - HttpUtils.filterCookies( - "theme=dark; ", - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo("theme=dark; ") - } - - @Test - fun `cookie filter replaces comma-separated malformed cookies in quoted values`() { - assertThat( - HttpUtils.filterCookies( - "theme=\"dark, sessionId=secret\"", - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo("[Filtered]") - } - - @Test - fun `cookie filter replaces space-separated malformed cookies in quoted values`() { - assertThat( - HttpUtils.filterCookies( - "theme=\"dark sessionId=secret\"", - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .isEqualTo("[Filtered]") - } - - @Test - fun `cookie allow list never exposes malformed pairs`() { - assertThat( - HttpUtils.filterCookies( - "theme=dark; opaque; =secret", - KeyValueCollectionBehavior.allowList("theme", "opaque"), - emptyList(), - ) - ) - .isEqualTo("theme=dark;[Filtered];[Filtered]") - } - - @Test - fun `set cookie filter preserves attributes`() { - assertThat( - HttpUtils.filterSetCookie( - "sessionId=secret; Path=/; HttpOnly; SameSite=Lax", - KeyValueCollectionBehavior.denyList(), - ) - ) - .isEqualTo("sessionId=[Filtered]; Path=/; HttpOnly; SameSite=Lax") - } - - @Test - fun `set cookie filter preserves empty and padded base64 values`() { - assertThat( - HttpUtils.filterSetCookie( - "data=YWJjZA==; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=3600; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax", - KeyValueCollectionBehavior.denyList(), - ) - ) - .isEqualTo( - "data=YWJjZA==; Expires=Wed, 09 Jun 2021 10:18:14 GMT; Max-Age=3600; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax" - ) - assertThat( - HttpUtils.filterSetCookie( - "empty=; Path=/", - KeyValueCollectionBehavior.denyList(), - ) - ) - .isEqualTo("empty=; Path=/") - } - - @Test - fun `set cookie allow list retains allowed non-sensitive value and attributes`() { - assertThat( - HttpUtils.filterSetCookie( - "theme=dark; Path=/; Secure", - KeyValueCollectionBehavior.allowList("theme"), - ) - ) - .isEqualTo("theme=dark; Path=/; Secure") - } - - @Test - fun `set cookie filter replaces malformed cookie pair and discards attributes`() { - assertThat( - HttpUtils.filterSetCookie( - "opaque; Path=/; HttpOnly", - KeyValueCollectionBehavior.denyList(), - ) - ) - .isEqualTo("[Filtered]") - assertThat( - HttpUtils.filterSetCookie( - "=secret; Path=/; HttpOnly", - KeyValueCollectionBehavior.denyList(), - ) - ) - .isEqualTo("[Filtered]") - } - - @Test - fun `set cookie allow list never exposes malformed cookie pair`() { - assertThat( - HttpUtils.filterSetCookie( - "opaque; Path=/; HttpOnly", - KeyValueCollectionBehavior.allowList("opaque"), - ) - ) - .isEqualTo("[Filtered]") - } - - @Test - fun `set cookie filter disables collection in off mode`() { - assertThat( - HttpUtils.filterSetCookie( - "theme=dark; Path=/", - KeyValueCollectionBehavior.off(), - ) - ) - .isNull() - } - - @Test - fun `cookie header filter processes every header value`() { - assertThat( - HttpUtils.filterCookiesFromHeader( - listOf("theme=dark; SID=secret", "language=en"), - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .containsExactly("theme=dark; SID=[Filtered]", "language=en") - .inOrder() - } - - @Test - fun `cookie header filter skips null header values`() { - assertThat( - HttpUtils.filterCookiesFromHeader( - java.util.Arrays.asList("theme=dark", null), - KeyValueCollectionBehavior.denyList(), - emptyList(), - ) - ) - .containsExactly("theme=dark") - } - @Test fun `header filter disables collection in off mode`() { val filtered = @@ -406,85 +113,4 @@ class HttpUtilsTest { "[Filtered]", ) } - - @Test - fun `null enumeration returns null when filtering security cookies from headers`() { - val enumeration: Enumeration? = null - val headers = HttpUtils.filterOutSecurityCookiesFromHeader(enumeration, "Cookie", emptyList()) - - assertNull(headers) - } - - @Test - fun `null list returns null when filtering security cookies from headers`() { - val list: List? = null - val headers = HttpUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", emptyList()) - - assertNull(headers) - } - - @Test - fun `enumeration works when filtering security cookies from headers`() { - val enumeration: Enumeration? = - StringTokenizer( - "Cookie_2=value2; Cookie_3=value3; JSESSIONID=123456789; mysessioncookiename=1F54D793F432FEE4CFC6A3FAED6D062F|Cookie_1=value1; SID=987654312", - "|", - ) - as Enumeration - val headers = - HttpUtils.filterOutSecurityCookiesFromHeader( - enumeration, - "Cookie", - listOf("mysessioncookiename"), - ) - - assertNotNull(headers) - assertEquals(2, headers.size) - assertEquals( - "Cookie_2=value2; Cookie_3=value3; JSESSIONID=[Filtered]; mysessioncookiename=[Filtered]", - headers!![0], - ) - assertEquals("Cookie_1=value1; SID=[Filtered]", headers!![1]) - } - - @Test - fun `list works when filtering security cookies from headers`() { - val list: List? = - listOf( - "Cookie_2=value2; Cookie_3=value3; JSESSIONID=123456789; mysessioncookiename=1F54D793F432FEE4CFC6A3FAED6D062F", - "Cookie_1=value1; SID=987654312", - ) - val headers = - HttpUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) - - assertNotNull(headers) - assertEquals(2, headers.size) - assertEquals( - "Cookie_2=value2; Cookie_3=value3; JSESSIONID=[Filtered]; mysessioncookiename=[Filtered]", - headers!![0], - ) - assertEquals("Cookie_1=value1; SID=[Filtered]", headers!![1]) - } - - @Test - fun `filtering security cookies from header works for corrupted string`() { - val list: List? = listOf("Cookie_1=value1;; SID=; JSESSIONID; =") - val headers = - HttpUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) - - assertNotNull(headers) - assertEquals(1, headers.size) - assertEquals("Cookie_1=value1;; SID=[Filtered]; JSESSIONID=[Filtered]; =", headers!![0]) - } - - @Test - fun `filtering security cookies from header works for null string`() { - val list: List? = listOf(null) - val headers = - HttpUtils.filterOutSecurityCookiesFromHeader(list, "Cookie", listOf("mysessioncookiename")) - - assertNotNull(headers) - assertEquals(1, headers.size) - assertEquals(null, headers!![0]) - } } From a69b1d731965da5051355dffcf8a8fed6aa5f82d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 7 Sep 2026 10:20:13 +0200 Subject: [PATCH 55/63] fix(core): Avoid sharing Data Collection fallbacks Create key-value fallback behaviors for each resolver lookup so mutations cannot leak across cookie, query parameter, and header policies. Refs #5666 Co-Authored-By: Claude --- .../main/java/io/sentry/DataCollectionResolver.java | 12 +++++------- .../java/io/sentry/DataCollectionResolverTest.kt | 11 +++++++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/sentry/src/main/java/io/sentry/DataCollectionResolver.java b/sentry/src/main/java/io/sentry/DataCollectionResolver.java index 17e40ab676c..d4aea259703 100644 --- a/sentry/src/main/java/io/sentry/DataCollectionResolver.java +++ b/sentry/src/main/java/io/sentry/DataCollectionResolver.java @@ -9,10 +9,6 @@ @ApiStatus.Internal public final class DataCollectionResolver { - private static final @NotNull KeyValueCollectionBehavior OFF = KeyValueCollectionBehavior.off(); - private static final @NotNull KeyValueCollectionBehavior EMPTY_DENY_LIST = - KeyValueCollectionBehavior.denyList(); - private final @NotNull SentryOptions options; DataCollectionResolver(final @NotNull SentryOptions options) { @@ -71,9 +67,11 @@ public boolean isGraphqlVariablesWithLegacyAlways() { return cookies; } if (isDataCollectionConfigured()) { - return EMPTY_DENY_LIST; + return KeyValueCollectionBehavior.denyList(); } - return options.isSendDefaultPii() ? EMPTY_DENY_LIST : OFF; + return options.isSendDefaultPii() + ? KeyValueCollectionBehavior.denyList() + : KeyValueCollectionBehavior.off(); } public @NotNull KeyValueCollectionBehavior getUrlQueryParams() { @@ -128,7 +126,7 @@ private boolean explicitOrDefault( private @NotNull KeyValueCollectionBehavior explicitOrEmptyDenyList( final @Nullable KeyValueCollectionBehavior explicit) { - return explicit != null ? explicit : EMPTY_DENY_LIST; + return explicit != null ? explicit : KeyValueCollectionBehavior.denyList(); } private boolean isHttpBodyEnabled( diff --git a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt index a49c56e9534..637619a6e28 100644 --- a/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt +++ b/sentry/src/test/java/io/sentry/DataCollectionResolverTest.kt @@ -244,6 +244,17 @@ class DataCollectionResolverTest { assertThat(options.dataCollectionResolver.cookies).isEqualTo(KeyValueCollectionBehavior.off()) } + @Test + fun `mutating one fallback key-value behavior does not affect other getters`() { + val resolver = SentryOptions().apply { dataCollection.setUserInfo(true) }.dataCollectionResolver + + resolver.cookies.terms = listOf("custom-cookie") + + assertThat(resolver.urlQueryParams.terms).doesNotContain("custom-cookie") + assertThat(resolver.httpRequestHeaders.terms).doesNotContain("custom-cookie") + assertThat(resolver.httpResponseHeaders.terms).doesNotContain("custom-cookie") + } + @Test fun `cookies use default deny list when unset and sendDefaultPii is true`() { val options = SentryOptions().apply { isSendDefaultPii = true } From 4edd118490917c28c94eddebda7a5a4eb9e8bbbd Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 7 Sep 2026 11:14:18 +0200 Subject: [PATCH 56/63] fix(core): Support WebSocket URL parsing Treat ws and wss as valid hierarchical URIs without requiring JVM URL handlers. This preserves Ktor WebSocket span descriptions and query filtering instead of falling back to an unknown URL. Refs #5666 Co-Authored-By: Claude --- .../main/java/io/sentry/util/UrlUtils.java | 5 +++ .../test/java/io/sentry/util/UrlUtilsTest.kt | 36 ++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/sentry/src/main/java/io/sentry/util/UrlUtils.java b/sentry/src/main/java/io/sentry/util/UrlUtils.java index f8fdcd273a6..289a80a17fd 100644 --- a/sentry/src/main/java/io/sentry/util/UrlUtils.java +++ b/sentry/src/main/java/io/sentry/util/UrlUtils.java @@ -55,6 +55,11 @@ public final class UrlUtils { } private static boolean isValidAbsoluteUrl(final @NotNull URI uri) { + final @Nullable String scheme = uri.getScheme(); + if ("ws".equalsIgnoreCase(scheme) || "wss".equalsIgnoreCase(scheme)) { + return !uri.isOpaque() && uri.getRawAuthority() != null && !uri.getRawAuthority().isEmpty(); + } + try { uri.toURL(); } catch (Exception e) { diff --git a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt index 18c9444917c..f7bdbcbcfb1 100644 --- a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt @@ -357,10 +357,36 @@ class UrlUtilsTest { } @Test - fun `does not extract details from websockets uri`() { - val urlDetails = UrlUtils.parse("wss://example.com/socket") - assertNull(urlDetails.url) - assertNull(urlDetails.query) - assertNull(urlDetails.fragment) + fun `extracts details from websocket uri`() { + val urlDetails = UrlUtils.parse("ws://example.com/socket?channel=updates#top") + + assertThat(urlDetails.url).isEqualTo("ws://example.com/socket") + assertThat(urlDetails.query).isEqualTo("channel=updates") + assertThat(urlDetails.fragment).isEqualTo("top") + } + + @Test + fun `filters query parameters from secure websocket uri`() { + val options = SentryOptions().also { it.dataCollection.setUserInfo(false) } + val urlDetails = + UrlUtils.parse( + "wss://example.com/socket?channel=updates&token=secret", + options.dataCollectionResolver, + ) + + assertThat(urlDetails.url).isEqualTo("wss://example.com/socket") + assertThat(urlDetails.query).isEqualTo("channel=updates&token=[Filtered]") + assertThat(urlDetails.fragment).isNull() + } + + @Test + fun `does not extract details from websocket uri without authority`() { + listOf("ws:example.com/socket", "wss:///socket").forEach { url -> + val urlDetails = UrlUtils.parse(url) + + assertThat(urlDetails.url).isNull() + assertThat(urlDetails.query).isNull() + assertThat(urlDetails.fragment).isNull() + } } } From 1cd804463f552e1bf9984674155de32cfeb02e58 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 7 Sep 2026 11:16:57 +0200 Subject: [PATCH 57/63] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f69853487f..15de6e5fa4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Support `ws` and `wss` URL parsing for WebSocket instrumentation ([#6064](https://github.com/getsentry/sentry-java/pull/6064)) + ## 8.55.0 ### Features From 33078e41b0833f30af16150e2fa474c2ba035a46 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 7 Sep 2026 11:21:01 +0200 Subject: [PATCH 58/63] test(okhttp): Use valid Set-Cookie fixture Exercise response cookie filtering with a valid cookie and preserve its attributes in the expected output. Co-Authored-By: Claude --- .../src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt index 8ca2aa6f3cb..a11398fed57 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt @@ -56,7 +56,7 @@ class SentryOkHttpUtilsTest { MockResponse() .setBody(responseBody) .addHeader("myResponseHeader", "myValue") - .addHeader("Set-Cookie", "setCookie") + .addHeader("Set-Cookie", "theme=dark; Path=/") .setSocketPolicy(socketPolicy) .setResponseCode(httpStatusCode) ) @@ -138,7 +138,7 @@ class SentryOkHttpUtilsTest { .captureEvent( check { assertEquals("theme=[Filtered]; sessionId=[Filtered]", it.request!!.cookies) - assertEquals("setCookie", it.contexts.response!!.cookies) + assertEquals("theme=[Filtered]; Path=/", it.contexts.response!!.cookies) }, any(), ) @@ -174,7 +174,7 @@ class SentryOkHttpUtilsTest { .captureEvent( check { assertEquals("theme=dark; sessionId=[Filtered]", it.request!!.cookies) - assertEquals("setCookie", it.contexts.response!!.cookies) + assertEquals("theme=dark; Path=/", it.contexts.response!!.cookies) }, any(), ) From 7d0de1b2c206e0fd70eb961e692b12b8f70702b7 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 7 Sep 2026 11:55:05 +0200 Subject: [PATCH 59/63] revert: fix(opentelemetry): Exclude queries from span descriptions Revert the OpenTelemetry span-description normalization from this stack PR. Preserve completed OpenTelemetry URL and target values when deriving descriptions. Refs #5666 Co-Authored-By: Claude --- .../SpanDescriptionExtractor.java | 11 +++++----- .../kotlin/SpanDescriptionExtractorTest.kt | 21 ++----------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index af6d1b74e74..3af3d8f96f0 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -10,7 +10,6 @@ import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes; import io.sentry.SentryOptions; import io.sentry.protocol.TransactionNameSource; -import io.sentry.util.UrlUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -72,14 +71,16 @@ private OtelSpanInfo descriptionForHttpMethod( final @Nullable String httpTarget = attributes.get(HttpIncubatingAttributes.HTTP_TARGET); final @Nullable String httpRoute = attributes.get(HttpAttributes.HTTP_ROUTE); @Nullable String httpPath = httpRoute; - if (httpPath == null && httpTarget != null) { - httpPath = UrlUtils.parse(httpTarget).getUrl(); + if (httpPath == null) { + httpPath = httpTarget; } final @NotNull String op = opBuilder.toString(); final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); - if (urlFull != null && httpPath == null) { - httpPath = UrlUtils.parse(urlFull).getUrl(); + if (urlFull != null) { + if (httpPath == null) { + httpPath = urlFull; + } } final @Nullable String urlPath = attributes.get(UrlAttributes.URL_PATH); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt index 5100ff715bb..a43afb849e6 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -113,7 +113,7 @@ class SpanDescriptionExtractorTest { val info = whenExtractingSpanInfo() assertEquals("http.server", info.op) - assertEquals("GET https://sentry.io/some/path", info.description) + assertEquals("GET https://sentry.io/some/path?q=1#top", info.description) assertEquals(TransactionNameSource.URL, info.transactionNameSource) } @@ -132,7 +132,7 @@ class SpanDescriptionExtractorTest { } @Test - fun `uses HTTP_ROUTE over HTTP_TARGET for description`() { + fun `uses HTTP_TARGET for description`() { givenSpanKind(SpanKind.SERVER) givenAttributes( mapOf( @@ -150,23 +150,6 @@ class SpanDescriptionExtractorTest { assertEquals(TransactionNameSource.ROUTE, info.transactionNameSource) } - @Test - fun `removes query and fragment from HTTP_TARGET description`() { - givenSpanKind(SpanKind.SERVER) - givenAttributes( - mapOf( - HttpAttributes.HTTP_REQUEST_METHOD to "GET", - HttpIncubatingAttributes.HTTP_TARGET to "/checkout?page=1&token=secret#details", - ) - ) - - val info = whenExtractingSpanInfo() - - assertEquals("http.server", info.op) - assertEquals("GET /checkout", info.description) - assertEquals(TransactionNameSource.URL, info.transactionNameSource) - } - @Test fun `uses span name as description fallback`() { givenSpanKind(SpanKind.SERVER) From c40ffe5179ed91812df123927e02a8caf598913a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 7 Sep 2026 12:56:06 +0200 Subject: [PATCH 60/63] fix(core): [Data Collection 24] Narrow utility exception handling Catch only the recoverable failures produced while filtering GraphQL bodies and decoding query parameter names. Preserve fail-closed handling for malformed GraphQL Unicode escapes without swallowing fatal JVM errors. Refs #5666 Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/util/GraphqlUtils.java | 3 ++- sentry/src/main/java/io/sentry/util/HttpUtils.java | 3 ++- sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt | 9 +++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/util/GraphqlUtils.java b/sentry/src/main/java/io/sentry/util/GraphqlUtils.java index 06893e6f5a8..faffc98afa5 100644 --- a/sentry/src/main/java/io/sentry/util/GraphqlUtils.java +++ b/sentry/src/main/java/io/sentry/util/GraphqlUtils.java @@ -4,6 +4,7 @@ import io.sentry.JsonObjectReader; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import java.io.IOException; import java.io.StringReader; import java.io.StringWriter; import java.util.ArrayList; @@ -56,7 +57,7 @@ private GraphqlUtils() {} final @NotNull StringWriter writer = new StringWriter(); options.getSerializer().serialize(filtered, writer); return writer.toString(); - } catch (Throwable e) { + } catch (IOException | NumberFormatException e) { options.getLogger().log(SentryLevel.ERROR, "Failed to filter GraphQL request body.", e); return null; } diff --git a/sentry/src/main/java/io/sentry/util/HttpUtils.java b/sentry/src/main/java/io/sentry/util/HttpUtils.java index faecd8b765a..59ed67eb9f8 100644 --- a/sentry/src/main/java/io/sentry/util/HttpUtils.java +++ b/sentry/src/main/java/io/sentry/util/HttpUtils.java @@ -4,6 +4,7 @@ import io.sentry.HttpStatusCodeRange; import io.sentry.KeyValueCollectionBehavior; +import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.Arrays; import java.util.LinkedHashMap; @@ -127,7 +128,7 @@ public static boolean containsSensitiveHeader(final @NotNull String header) { private static @NotNull String decodeQueryParamName(final @NotNull String name) { try { return URLDecoder.decode(name, "UTF-8"); - } catch (Throwable ignored) { + } catch (IllegalArgumentException | UnsupportedEncodingException ignored) { return name; } } diff --git a/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt index ea72368e74d..d5cc325baef 100644 --- a/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/GraphqlUtilsTest.kt @@ -82,6 +82,15 @@ class GraphqlUtilsTest { assertThat(result).isNull() } + @Test + fun `returns null for a GraphQL request body containing a malformed unicode escape`() { + val options = SentryOptions().also { it.dataCollection.graphql.setDocument(false) } + + val result = GraphqlUtils.filterRequestBody("""{"query":"\u12G4"}""", options) + + assertThat(result).isNull() + } + private companion object { const val REQUEST_BODY = """{"operationName":"GetUser","variables":{"id":"123"},"query":"query { viewer { name } }"}""" From 1f2aba47b18c59b44b658958be97e3a023161192 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 8 Sep 2026 08:03:15 +0200 Subject: [PATCH 61/63] fix(spring): Preserve URL credential filtering Build WebClient span descriptions from sanitized URL details in legacy mode. Avoid exposing URL credentials when Data Collection is not explicitly configured. Co-Authored-By: Claude --- .../spring7/tracing/SentrySpanClientWebRequestFilter.java | 7 +------ .../jakarta/tracing/SentrySpanClientWebRequestFilter.java | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java index ae2446f121f..df3cd548218 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java @@ -49,12 +49,7 @@ public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url().toString(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.method().name(); - span.setDescription( - method - + " " - + (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() - ? urlDetails.getUrlOrFallback() - : request.url())); + span.setDescription(method + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { urlDetails.applyToSpan(span); diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java index 51f68afd3f8..1920a8f6ea2 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentrySpanClientWebRequestFilter.java @@ -49,12 +49,7 @@ public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url().toString(), scopes.getOptions().getDataCollectionResolver()); final @NotNull String method = request.method().name(); - span.setDescription( - method - + " " - + (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured() - ? urlDetails.getUrlOrFallback() - : request.url())); + span.setDescription(method + " " + urlDetails.getUrlOrFallback()); span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); if (scopes.getOptions().getDataCollectionResolver().isDataCollectionConfigured()) { urlDetails.applyToSpan(span); From 2434ba07cff080013c22e704086672ef4eea845d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 8 Sep 2026 08:08:16 +0200 Subject: [PATCH 62/63] ref(core): Centralize HTTP client cookie filtering Move Data Collection and legacy cookie policy selection into CookieUtils. Remove duplicated wrappers from OkHttp, Ktor, and Apollo integrations. Co-Authored-By: Claude --- .../apollo3/SentryApollo3HttpInterceptor.kt | 27 ++---------- .../apollo4/SentryApollo4HttpInterceptor.kt | 27 ++---------- .../ktorClient/SentryKtorClientUtils.kt | 26 +---------- .../io/sentry/okhttp/SentryOkHttpUtils.kt | 26 +---------- sentry/api/sentry.api | 2 + .../main/java/io/sentry/util/CookieUtils.java | 17 ++++++++ .../java/io/sentry/util/CookieUtilsTest.kt | 43 +++++++++++++++++++ 7 files changed, 72 insertions(+), 96 deletions(-) diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 8c84630a0ae..94ba52cb592 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -271,28 +271,6 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value - private fun getRequestCookies(headers: List): String? { - val cookies = getHeader("Cookie", headers) - return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - CookieUtils.filterCookies(cookies, scopes.options.dataCollectionResolver.cookies, null) - } else if (scopes.options.isSendDefaultPii) { - cookies - } else { - null - } - } - - private fun getResponseCookies(headers: List): String? { - val cookies = getHeader("Set-Cookie", headers) - return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - CookieUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) - } else if (scopes.options.isSendDefaultPii) { - cookies - } else { - null - } - } - private fun getRequestHeaders(headers: List): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = mutableMapOf() @@ -414,7 +392,7 @@ constructor( val sentryRequest = Request().apply { urlDetails.applyToRequest(this) - cookies = getRequestCookies(request.headers) + cookies = CookieUtils.filterCookies(getHeader("Cookie", request.headers), scopes.options) method = request.method.name headers = getRequestHeaders(request.headers) apiTarget = "graphql" @@ -440,7 +418,8 @@ constructor( val sentryResponse = Response().apply { - cookies = getResponseCookies(response.headers) + cookies = + CookieUtils.filterSetCookie(getHeader("Set-Cookie", response.headers), scopes.options) headers = getResponseHeaders(response.headers) statusCode = response.statusCode diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt index a2e93fca7d1..54ad1d50fdb 100644 --- a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -270,28 +270,6 @@ constructor( private fun getHeader(key: String, headers: List): String? = headers.firstOrNull { it.name.equals(key, true) }?.value - private fun getRequestCookies(headers: List): String? { - val cookies = getHeader("Cookie", headers) - return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - CookieUtils.filterCookies(cookies, scopes.options.dataCollectionResolver.cookies, null) - } else if (scopes.options.isSendDefaultPii) { - cookies - } else { - null - } - } - - private fun getResponseCookies(headers: List): String? { - val cookies = getHeader("Set-Cookie", headers) - return if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - CookieUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) - } else if (scopes.options.isSendDefaultPii) { - cookies - } else { - null - } - } - private fun getRequestHeaders(headers: List): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = mutableMapOf() @@ -413,7 +391,7 @@ constructor( val sentryRequest = Request().apply { urlDetails.applyToRequest(this) - cookies = getRequestCookies(request.headers) + cookies = CookieUtils.filterCookies(getHeader("Cookie", request.headers), scopes.options) method = request.method.name headers = getRequestHeaders(request.headers) apiTarget = "graphql" @@ -439,7 +417,8 @@ constructor( val sentryResponse = Response().apply { - cookies = getResponseCookies(response.headers) + cookies = + CookieUtils.filterSetCookie(getHeader("Set-Cookie", response.headers), scopes.options) headers = getResponseHeaders(response.headers) statusCode = response.statusCode diff --git a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt index 257027329c3..b8c1385ed92 100644 --- a/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt +++ b/sentry-ktor-client/src/main/java/io/sentry/ktorClient/SentryKtorClientUtils.kt @@ -38,7 +38,7 @@ internal object SentryKtorClientUtils { val sentryRequest = io.sentry.protocol.Request().apply { urlDetails.applyToRequest(this) - cookies = getRequestCookies(scopes, request.headers["Cookie"]) + cookies = CookieUtils.filterCookies(request.headers["Cookie"], scopes.options) method = request.method.value headers = getRequestHeaders(scopes, request.headers) bodySize = request.content.contentLength @@ -46,7 +46,7 @@ internal object SentryKtorClientUtils { val sentryResponse = io.sentry.protocol.Response().apply { - cookies = getResponseCookies(scopes, response.headers["Set-Cookie"]) + cookies = CookieUtils.filterSetCookie(response.headers["Set-Cookie"], scopes.options) headers = getResponseHeaders(scopes, response.headers) statusCode = response.status.value try { @@ -66,28 +66,6 @@ internal object SentryKtorClientUtils { scopes.captureEvent(event, hint) } - private fun getRequestCookies(scopes: IScopes, cookies: String?): String? = - if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - CookieUtils.filterCookies( - cookies, - scopes.options.dataCollectionResolver.cookies, - null, - ) - } else if (scopes.options.isSendDefaultPii) { - cookies - } else { - null - } - - private fun getResponseCookies(scopes: IScopes, cookies: String?): String? = - if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - CookieUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) - } else if (scopes.options.isSendDefaultPii) { - cookies - } else { - null - } - private fun getRequestHeaders(scopes: IScopes, headers: Headers): MutableMap? { if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { val requestHeaders = diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt index 0207880edff..1be993c9544 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt @@ -38,7 +38,7 @@ internal object SentryOkHttpUtils { val sentryRequest = io.sentry.protocol.Request().apply { urlDetails.applyToRequest(this) - cookies = getRequestCookies(scopes, request.headers["Cookie"]) + cookies = CookieUtils.filterCookies(request.headers["Cookie"], scopes.options) method = request.method headers = getRequestHeaders(scopes, request.headers) @@ -47,7 +47,7 @@ internal object SentryOkHttpUtils { val sentryResponse = io.sentry.protocol.Response().apply { - cookies = getResponseCookies(scopes, response.headers["Set-Cookie"]) + cookies = CookieUtils.filterSetCookie(response.headers["Set-Cookie"], scopes.options) headers = getResponseHeaders(scopes, response.headers) statusCode = response.code @@ -66,28 +66,6 @@ internal object SentryOkHttpUtils { } } - private fun getRequestCookies(scopes: IScopes, cookies: String?): String? = - if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - CookieUtils.filterCookies( - cookies, - scopes.options.dataCollectionResolver.cookies, - null, - ) - } else if (scopes.options.isSendDefaultPii) { - cookies - } else { - null - } - - private fun getResponseCookies(scopes: IScopes, cookies: String?): String? = - if (scopes.options.dataCollectionResolver.isDataCollectionConfigured) { - CookieUtils.filterSetCookie(cookies, scopes.options.dataCollectionResolver.cookies) - } else if (scopes.options.isSendDefaultPii) { - cookies - } else { - null - } - private fun getRequestHeaders( scopes: IScopes, requestHeaders: Headers, diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fed592bf8cc..df06ff5a0f9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7825,12 +7825,14 @@ public final class io/sentry/util/CookieUtils { public static final field COOKIE_HEADER_NAME Ljava/lang/String; public fun ()V public static fun filterCookies (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/lang/String; + public static fun filterCookies (Ljava/lang/String;Lio/sentry/SentryOptions;)Ljava/lang/String; public static fun filterCookiesFromHeader (Ljava/util/Enumeration;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; public static fun filterCookiesFromHeader (Ljava/util/List;Lio/sentry/KeyValueCollectionBehavior;Ljava/util/List;)Ljava/util/List; public static fun filterOutSecurityCookies (Ljava/lang/String;Ljava/util/List;)Ljava/lang/String; public static fun filterOutSecurityCookiesFromHeader (Ljava/util/Enumeration;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; public static fun filterOutSecurityCookiesFromHeader (Ljava/util/List;Ljava/lang/String;Ljava/util/List;)Ljava/util/List; public static fun filterSetCookie (Ljava/lang/String;Lio/sentry/KeyValueCollectionBehavior;)Ljava/lang/String; + public static fun filterSetCookie (Ljava/lang/String;Lio/sentry/SentryOptions;)Ljava/lang/String; public static fun isSecurityCookie (Ljava/lang/String;Ljava/util/List;)Z } diff --git a/sentry/src/main/java/io/sentry/util/CookieUtils.java b/sentry/src/main/java/io/sentry/util/CookieUtils.java index 203eee291a9..935f98aaf1a 100644 --- a/sentry/src/main/java/io/sentry/util/CookieUtils.java +++ b/sentry/src/main/java/io/sentry/util/CookieUtils.java @@ -3,6 +3,7 @@ import static io.sentry.util.UrlUtils.SENSITIVE_DATA_SUBSTITUTE; import io.sentry.KeyValueCollectionBehavior; +import io.sentry.SentryOptions; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -57,6 +58,14 @@ public final class CookieUtils { return filteredHeaders; } + public static @Nullable String filterCookies( + final @Nullable String cookies, final @NotNull SentryOptions options) { + if (!options.getDataCollectionResolver().isDataCollectionConfigured()) { + return options.isSendDefaultPii() ? cookies : null; + } + return filterCookies(cookies, options.getDataCollectionResolver().getCookies(), null); + } + public static @Nullable String filterCookies( final @Nullable String cookies, final @NotNull KeyValueCollectionBehavior behavior, @@ -77,6 +86,14 @@ public final class CookieUtils { return filteredCookies.toString(); } + public static @Nullable String filterSetCookie( + final @Nullable String cookie, final @NotNull SentryOptions options) { + if (!options.getDataCollectionResolver().isDataCollectionConfigured()) { + return options.isSendDefaultPii() ? cookie : null; + } + return filterSetCookie(cookie, options.getDataCollectionResolver().getCookies()); + } + public static @Nullable String filterSetCookie( final @Nullable String cookie, final @NotNull KeyValueCollectionBehavior behavior) { if (cookie == null || behavior.getMode() == KeyValueCollectionBehavior.Mode.OFF) { diff --git a/sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt b/sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt index bd0fa46d236..0c000d31e41 100644 --- a/sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/CookieUtilsTest.kt @@ -2,6 +2,7 @@ package io.sentry.util import com.google.common.truth.Truth.assertThat import io.sentry.KeyValueCollectionBehavior +import io.sentry.SentryOptions import java.util.Enumeration import java.util.StringTokenizer import kotlin.test.Test @@ -10,6 +11,48 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull class CookieUtilsTest { + @Test + fun `options cookie filters omit cookies in legacy mode without default pii`() { + val options = SentryOptions() + + assertThat(CookieUtils.filterCookies("sessionId=secret", options)).isNull() + assertThat(CookieUtils.filterSetCookie("sessionId=secret; Path=/", options)).isNull() + } + + @Test + fun `options cookie filters preserve cookies in legacy mode with default pii`() { + val options = SentryOptions().apply { isSendDefaultPii = true } + + assertThat(CookieUtils.filterCookies("sessionId=secret", options)).isEqualTo("sessionId=secret") + assertThat(CookieUtils.filterSetCookie("sessionId=secret; Path=/", options)) + .isEqualTo("sessionId=secret; Path=/") + } + + @Test + fun `options cookie filters apply configured policy`() { + val options = + SentryOptions().apply { + dataCollection.cookies = KeyValueCollectionBehavior.denyList("customer") + } + + assertThat(CookieUtils.filterCookies("theme=dark; customerId=123", options)) + .isEqualTo("theme=dark; customerId=[Filtered]") + assertThat(CookieUtils.filterSetCookie("customerId=123; Path=/", options)) + .isEqualTo("customerId=[Filtered]; Path=/") + } + + @Test + fun `options cookie filters honor explicitly disabled collection`() { + val options = + SentryOptions().apply { + isSendDefaultPii = true + dataCollection.cookies = KeyValueCollectionBehavior.off() + } + + assertThat(CookieUtils.filterCookies("theme=dark", options)).isNull() + assertThat(CookieUtils.filterSetCookie("theme=dark; Path=/", options)).isNull() + } + @Test fun `cookie filter disables collection in off mode`() { assertThat( From 29e42b8874b58570d2b785da090fd89b11b186d1 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 8 Sep 2026 13:46:00 +0200 Subject: [PATCH 63/63] docs: Document Data Collection configuration Describe Data Collection defaults, migration from sendDefaultPii, and the supported configuration mechanisms. Include examples for key-value filtering and HTTP body selection so users can adopt the new controls safely. Refs #5666 Co-Authored-By: Claude --- CHANGELOG.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15de6e5fa4a..0b70ad3e496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,109 @@ ## Unreleased +### Features + +- Add `dataCollection`, a fine-grained replacement for `sendDefaultPii`, for controlling data collected automatically by SDK integrations ([#5759](https://github.com/getsentry/sentry-java/pull/5759)) + - `sendDefaultPii` remains supported for backwards compatibility. When `dataCollection` is not configured, the SDK preserves the existing `sendDefaultPii` behavior. + - Configuring any `dataCollection` option makes it the source of truth. `sendDefaultPii` is then ignored, and omitted `dataCollection` options use the defaults below. + - Data explicitly supplied through APIs such as `Sentry.setUser`, scopes, event processors, or `beforeSend` is not affected. + + | Option | Default | Behavior | + | --- | --- | --- | + | `userInfo` | `true` | Allows integrations to populate user identity and IP address information automatically. | + | `cookies` | `{ mode: DENY_LIST, terms: [] }` | Collects cookies while filtering sensitive values. | + | `httpHeaders.request` | `{ mode: DENY_LIST, terms: [] }` | Collects request headers while filtering sensitive values. | + | `httpHeaders.response` | `{ mode: DENY_LIST, terms: [] }` | Collects response headers while filtering sensitive values. | + | `httpBodies` | All supported body types | Collects supported incoming and outgoing request and response bodies. An empty set disables body collection. | + | `urlQueryParams` | `{ mode: DENY_LIST, terms: [] }` | Collects URL query parameters while filtering sensitive values. | + | `graphql.document` | `true` | Collects GraphQL documents. | + | `graphql.variables` | `true` | Collects GraphQL variables. | + | `databaseQueryData` | `true` | Allows collection of associated query data, such as bound parameters, write payloads, and results, where supported. Sanitized query statements and structural database metadata remain available. | + + Cookies, HTTP headers, and URL query parameters support three modes: + + - `OFF`: Do not collect the category. + - `DENY_LIST`: Collect values except those matching the built-in sensitive deny-list or additional configured terms. + - `ALLOW_LIST`: Only send plaintext values for matching terms. The built-in sensitive deny-list still applies. + + Matching is case-insensitive and partial. The built-in sensitive deny-list contains `auth`, `token`, `secret`, `password`, `passwd`, `pwd`, `key`, `jwt`, `bearer`, `sso`, `saml`, `csrf`, `xsrf`, `credentials`, `session`, `sid`, and `identity`. Filtered values are replaced with `"[Filtered]"`. Custom deny-list terms extend rather than replace this list. + + Configure all HTTP body types, a custom cookie deny-list, a request-header allow-list, and disable URL query parameter collection in an options callback: + + ```java + Sentry.init( + options -> { + options + .getDataCollection() + .setHttpBodies( + EnumSet.of( + HttpBodyType.INCOMING_REQUEST, + HttpBodyType.OUTGOING_REQUEST, + HttpBodyType.INCOMING_RESPONSE, + HttpBodyType.OUTGOING_RESPONSE)); + options + .getDataCollection() + .setCookies( + KeyValueCollectionBehavior.denyList( + "forwarded", "-ip", "remote-", "via", "-user")); + options + .getDataCollection() + .getHttpHeaders() + .setRequest( + KeyValueCollectionBehavior.allowList("content-type", "x-request-id")); + options + .getDataCollection() + .setUrlQueryParams(KeyValueCollectionBehavior.off()); + }); + ``` + + Configure the same options in `sentry.properties`: + + ```properties + data-collection.http-bodies=incoming_request,outgoing_request,incoming_response,outgoing_response + data-collection.cookies.mode=deny_list + data-collection.cookies.terms=forwarded,-ip,remote-,via,-user + data-collection.http-headers.request.mode=allow_list + data-collection.http-headers.request.terms=content-type,x-request-id + data-collection.url-query-params.mode=off + ``` + + Configure them with Spring Boot properties: + + ```properties + sentry.data-collection.http-bodies=incoming-request,outgoing-request,incoming-response,outgoing-response + sentry.data-collection.cookies.mode=deny-list + sentry.data-collection.cookies.terms=forwarded,-ip,remote-,via,-user + sentry.data-collection.http-headers.request.mode=allow-list + sentry.data-collection.http-headers.request.terms=content-type,x-request-id + sentry.data-collection.url-query-params.mode=off + ``` + + Configure them in `AndroidManifest.xml`: + + ```xml + + + + + + + ``` + + See the [Data Collection documentation](https://docs.sentry.io/platforms/java/configuration/options/#dataCollection) for all configuration keys, supported integrations, and migration guidance. + ### Fixes - Support `ws` and `wss` URL parsing for WebSocket instrumentation ([#6064](https://github.com/getsentry/sentry-java/pull/6064))