From dd4bdfc222cd86f8022e77a3aa1ce380e63a31e8 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 14 Jul 2026 13:58:32 +0200 Subject: [PATCH 01/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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 b13daf86577a47890a293c2ffdf6c5461bc6edba Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 10 Aug 2026 14:37:57 +0200 Subject: [PATCH 18/31] 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 19/31] 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 20/31] 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 a40c2e495c4db8d3b86f03e31faebef46e35afcd Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 10 Aug 2026 16:56:46 +0200 Subject: [PATCH 21/31] 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 b5030a595f438e6abfb163551d8a1d07ac70593e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 24 Aug 2026 14:34:27 +0200 Subject: [PATCH 22/31] 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 3d2759045303a187c688f01fb757020d00cccf64 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 27 Aug 2026 14:50:21 +0200 Subject: [PATCH 23/31] 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 24/31] 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 f1f23e18c06c602f3bd0f751a1b4dbdce1324c95 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 28 Aug 2026 09:40:34 +0200 Subject: [PATCH 25/31] 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 3a6fa4f7cd21158c31c37dd0039a7b5c6addd949 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 31 Aug 2026 06:21:47 +0200 Subject: [PATCH 26/31] 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 27/31] 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 28/31] 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 29/31] 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 30/31] 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 31/31] 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 =