From a3ab073bc64022089933f793f2cb3b405c1a72b0 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 1 Sep 2026 13:34:55 +0200 Subject: [PATCH 1/2] feat(core): Add Data Collection external options Parse flattened Data Collection settings from properties, system properties, and environment variables. Merge only configured values so omitted settings retain their documented or legacy behavior. Refs #5666 --- sentry/api/sentry.api | 2 + .../main/java/io/sentry/ExternalOptions.java | 105 ++++++++++++++++++ .../main/java/io/sentry/SentryOptions.java | 37 ++++++ .../java/io/sentry/ExternalOptionsTest.kt | 94 ++++++++++++++++ .../test/java/io/sentry/SentryOptionsTest.kt | 81 ++++++++++++++ 5 files changed, 319 insertions(+) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ed42a1c3ddc..3bebc5e6d68 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -547,6 +547,7 @@ public final class io/sentry/ExternalOptions { public fun getBundleIds ()Ljava/util/Set; public fun getContextTags ()Ljava/util/List; public fun getCron ()Lio/sentry/SentryOptions$Cron; + public fun getDataCollection ()Lio/sentry/DataCollection; public fun getDebug ()Ljava/lang/Boolean; public fun getDist ()Ljava/lang/String; public fun getDsn ()Ljava/lang/String; @@ -596,6 +597,7 @@ public final class io/sentry/ExternalOptions { public fun isStrictTraceContinuation ()Ljava/lang/Boolean; public fun setCaptureOpenTelemetryEvents (Ljava/lang/Boolean;)V public fun setCron (Lio/sentry/SentryOptions$Cron;)V + public fun setDataCollection (Lio/sentry/DataCollection;)V public fun setDebug (Ljava/lang/Boolean;)V public fun setDist (Ljava/lang/String;)V public fun setDsn (Ljava/lang/String;)V diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 4e44ea422ec..1232536c7d8 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -55,6 +55,7 @@ public final class ExternalOptions { private @Nullable Boolean sendModules; private @Nullable Boolean sendDefaultPii; + private @Nullable DataCollection dataCollection; private @Nullable Boolean enableBackpressureHandling; private @Nullable Boolean enableDatabaseTransactionTracing; private @Nullable Boolean enableCacheTracing; @@ -157,6 +158,7 @@ public final class ExternalOptions { options.setSendModules(propertiesProvider.getBooleanProperty("send-modules")); options.setSendDefaultPii(propertiesProvider.getBooleanProperty("send-default-pii")); + options.setDataCollection(parseDataCollection(propertiesProvider)); options.setIgnoredCheckIns(propertiesProvider.getListOrNull("ignored-checkins")); options.setIgnoredTransactions(propertiesProvider.getListOrNull("ignored-transactions")); @@ -246,6 +248,101 @@ public final class ExternalOptions { return options; } + private static @Nullable DataCollection parseDataCollection( + final @NotNull PropertiesProvider propertiesProvider) { + final DataCollection dataCollection = new DataCollection(false); + + final Boolean userInfo = propertiesProvider.getBooleanProperty("data-collection.user-info"); + if (userInfo != null) { + dataCollection.setUserInfo(userInfo); + } + + final Set httpBodies = parseHttpBodies(propertiesProvider); + if (httpBodies != null) { + dataCollection.setHttpBodies(httpBodies); + } + + final KeyValueCollectionBehavior cookies = + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.cookies"); + if (cookies != null) { + dataCollection.setCookies(cookies); + } + + final KeyValueCollectionBehavior requestHeaders = + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.http-headers.request"); + if (requestHeaders != null) { + dataCollection.getHttpHeaders().setRequest(requestHeaders); + } + + final KeyValueCollectionBehavior responseHeaders = + parseKeyValueCollectionBehavior( + propertiesProvider, "data-collection.http-headers.response"); + if (responseHeaders != null) { + dataCollection.getHttpHeaders().setResponse(responseHeaders); + } + + final KeyValueCollectionBehavior queryParams = + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.query-params"); + if (queryParams != null) { + dataCollection.setUrlQueryParams(queryParams); + } + + final Boolean graphqlDocument = + propertiesProvider.getBooleanProperty("data-collection.graphql.document"); + if (graphqlDocument != null) { + dataCollection.getGraphql().setDocument(graphqlDocument); + } + + final Boolean graphqlVariables = + propertiesProvider.getBooleanProperty("data-collection.graphql.variables"); + if (graphqlVariables != null) { + dataCollection.getGraphql().setVariables(graphqlVariables); + } + + final Boolean databaseQueryData = + propertiesProvider.getBooleanProperty("data-collection.database-query-data"); + if (databaseQueryData != null) { + dataCollection.setDatabaseQueryData(databaseQueryData); + } + + return dataCollection.isExplicitlyConfigured() ? dataCollection : null; + } + + private static @Nullable Set parseHttpBodies( + final @NotNull PropertiesProvider propertiesProvider) { + final List bodyTypes = propertiesProvider.getListOrNull("data-collection.http-bodies"); + if (bodyTypes == null) { + return null; + } + if (bodyTypes.size() == 1 && bodyTypes.get(0).isEmpty()) { + return Collections.emptySet(); + } + + final Set httpBodies = EnumSet.noneOf(HttpBodyType.class); + for (final String bodyType : bodyTypes) { + httpBodies.add(HttpBodyType.valueOf(bodyType.toUpperCase(Locale.ROOT))); + } + return httpBodies; + } + + private static @Nullable KeyValueCollectionBehavior parseKeyValueCollectionBehavior( + final @NotNull PropertiesProvider propertiesProvider, final @NotNull String property) { + final String modeValue = propertiesProvider.getProperty(property + ".mode"); + final List terms = propertiesProvider.getListOrNull(property + ".terms"); + if (modeValue == null && terms == null) { + return null; + } + + final KeyValueCollectionBehavior behavior = new KeyValueCollectionBehavior(); + if (modeValue != null) { + behavior.setMode(KeyValueCollectionBehavior.Mode.valueOf(modeValue.toUpperCase(Locale.ROOT))); + } + if (terms != null) { + behavior.setTerms(terms); + } + return behavior; + } + public @Nullable String getDsn() { return dsn; } @@ -501,6 +598,14 @@ public void setSendDefaultPii(final @Nullable Boolean sendDefaultPii) { this.sendDefaultPii = sendDefaultPii; } + public @Nullable DataCollection getDataCollection() { + return dataCollection; + } + + public void setDataCollection(final @Nullable DataCollection dataCollection) { + this.dataCollection = dataCollection; + } + public void setIgnoredCheckIns(final @Nullable List ignoredCheckIns) { this.ignoredCheckIns = ignoredCheckIns; } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index e312fe94c47..bde387d8f0b 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3627,6 +3627,9 @@ public void merge(final @NotNull ExternalOptions options) { if (options.isSendDefaultPii() != null) { setSendDefaultPii(options.isSendDefaultPii()); } + if (options.getDataCollection() != null) { + mergeDataCollection(options.getDataCollection()); + } if (options.isCaptureOpenTelemetryEvents() != null) { setCaptureOpenTelemetryEvents(options.isCaptureOpenTelemetryEvents()); } @@ -3692,6 +3695,40 @@ public void merge(final @NotNull ExternalOptions options) { } } + private void mergeDataCollection(final @NotNull DataCollection externalDataCollection) { + if (externalDataCollection.getUserInfo() != null) { + dataCollection.setUserInfo(externalDataCollection.getUserInfo()); + } + if (externalDataCollection.getHttpBodies() != null) { + dataCollection.setHttpBodies(externalDataCollection.getHttpBodies()); + } + if (externalDataCollection.getCookies() != null) { + dataCollection.setCookies(externalDataCollection.getCookies()); + } + if (externalDataCollection.getHttpHeaders().getRequest() != null) { + dataCollection + .getHttpHeaders() + .setRequest(externalDataCollection.getHttpHeaders().getRequest()); + } + if (externalDataCollection.getHttpHeaders().getResponse() != null) { + dataCollection + .getHttpHeaders() + .setResponse(externalDataCollection.getHttpHeaders().getResponse()); + } + if (externalDataCollection.getUrlQueryParams() != null) { + dataCollection.setUrlQueryParams(externalDataCollection.getUrlQueryParams()); + } + if (externalDataCollection.getGraphql().getDocument() != null) { + dataCollection.getGraphql().setDocument(externalDataCollection.getGraphql().getDocument()); + } + if (externalDataCollection.getGraphql().getVariables() != null) { + dataCollection.getGraphql().setVariables(externalDataCollection.getGraphql().getVariables()); + } + if (externalDataCollection.getDatabaseQueryData() != null) { + dataCollection.setDatabaseQueryData(externalDataCollection.getDatabaseQueryData()); + } + } + private @NotNull SdkVersion createSdkVersion() { final String version = BuildConfig.VERSION_NAME; final SdkVersion sdkVersion = new SdkVersion(BuildConfig.SENTRY_JAVA_SDK_NAME, version); diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index fee707d31f3..27056593a65 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -1,9 +1,11 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.config.PropertiesProviderFactory import java.lang.RuntimeException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -15,6 +17,98 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify class ExternalOptionsTest { + @Test + fun `does not create data collection when external properties are absent`() { + withPropertiesFile { assertThat(it.dataCollection).isNull() } + } + + @Test + fun `creates data collection using external properties`() { + withPropertiesFile( + listOf( + "data-collection.user-info=false", + "data-collection.http-bodies=incoming_request,outgoing_response", + "data-collection.cookies.mode=deny_list", + "data-collection.cookies.terms=authorization,session", + "data-collection.http-headers.request.mode=allow_list", + "data-collection.http-headers.request.terms=x-request-id,content-type", + "data-collection.http-headers.response.mode=off", + "data-collection.query-params.terms=search", + "data-collection.graphql.document=false", + "data-collection.graphql.variables=true", + "data-collection.database-query-data=false", + ) + ) { options -> + val dataCollection = options.dataCollection + + assertThat(dataCollection).isNotNull() + assertThat(dataCollection!!.userInfo).isFalse() + assertThat(dataCollection.httpBodies) + .containsExactly(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + assertThat(dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList("authorization", "session")) + assertThat(dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("x-request-id", "content-type")) + assertThat(dataCollection.httpHeaders.response).isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(dataCollection.urlQueryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList("search")) + assertThat(dataCollection.graphql.document).isFalse() + assertThat(dataCollection.graphql.variables).isTrue() + assertThat(dataCollection.databaseQueryData).isFalse() + } + } + + @Test + fun `empty HTTP bodies externally disables body collection`() { + withPropertiesFile("data-collection.http-bodies=") { options -> + assertThat(options.dataCollection).isNotNull() + assertThat(options.dataCollection!!.httpBodies).isEmpty() + } + } + + @Test + fun `invalid HTTP body type fails external parsing`() { + assertFailsWith { + withPropertiesFile("data-collection.http-bodies=invalid") {} + } + } + + @Test + fun `invalid collection mode fails external parsing`() { + assertFailsWith { + withPropertiesFile("data-collection.cookies.mode=invalid") {} + } + } + + @Test + fun `data collection booleans use default external parsing`() { + withPropertiesFile( + listOf( + "data-collection.user-info=invalid", + "data-collection.graphql.document=invalid", + "data-collection.graphql.variables=invalid", + "data-collection.database-query-data=invalid", + ) + ) { options -> + assertThat(options.dataCollection!!.userInfo).isFalse() + assertThat(options.dataCollection!!.graphql.document).isFalse() + assertThat(options.dataCollection!!.graphql.variables).isFalse() + assertThat(options.dataCollection!!.databaseQueryData).isFalse() + } + } + + @Test + fun `external data collection takes precedence over external send default PII`() { + withPropertiesFile(listOf("send-default-pii=false", "data-collection.cookies.mode=off")) { + externalOptions -> + val options = SentryOptions().apply { merge(externalOptions) } + + assertThat(options.isSendDefaultPii).isFalse() + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(options.dataCollectionResolver.cookies).isEqualTo(KeyValueCollectionBehavior.off()) + } + } + @Test fun `creates options with proxy using external properties`() { withPropertiesFile( diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index d5c7e6f3c7e..fa3f3296fc5 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -82,6 +82,87 @@ class SentryOptionsTest { assertThat(options.dataCollection.userInfo).isFalse() } + @Test + fun `merging absent external data collection preserves legacy mode`() { + val options = SentryOptions() + + options.merge(ExternalOptions()) + + assertThat(options.dataCollection.isExplicitlyConfigured()).isFalse() + } + + @Test + fun `merging external data collection applies only configured values`() { + val options = + SentryOptions().apply { + dataCollection.setUserInfo(false) + dataCollection.cookies = KeyValueCollectionBehavior.allowList("safe") + } + val externalOptions = + ExternalOptions().apply { + dataCollection = DataCollection().apply { graphql.setVariables(false) } + } + + options.merge(externalOptions) + + assertThat(options.dataCollection.userInfo).isFalse() + assertThat(options.dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.allowList("safe")) + assertThat(options.dataCollection.graphql.variables).isFalse() + } + + @Test + fun `merging external data collection applies every supported value`() { + val externalDataCollection = + DataCollection().apply { + setUserInfo(false) + httpBodies = setOf(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + cookies = KeyValueCollectionBehavior.denyList("cookie") + httpHeaders.request = KeyValueCollectionBehavior.allowList("request") + httpHeaders.response = KeyValueCollectionBehavior.off() + urlQueryParams = KeyValueCollectionBehavior.denyList("query") + graphql.setDocument(false) + graphql.setVariables(false) + setDatabaseQueryData(false) + } + val options = SentryOptions() + + options.merge(ExternalOptions().apply { dataCollection = externalDataCollection }) + + assertThat(options.dataCollection.userInfo).isFalse() + assertThat(options.dataCollection.httpBodies) + .containsExactly(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + assertThat(options.dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList("cookie")) + assertThat(options.dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("request")) + assertThat(options.dataCollection.httpHeaders.response) + .isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(options.dataCollection.urlQueryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList("query")) + assertThat(options.dataCollection.graphql.document).isFalse() + assertThat(options.dataCollection.graphql.variables).isFalse() + assertThat(options.dataCollection.databaseQueryData).isFalse() + } + + @Test + fun `external data collection takes precedence over send default PII`() { + val externalOptions = + ExternalOptions().apply { + isSendDefaultPii = false + dataCollection = DataCollection().apply { cookies = KeyValueCollectionBehavior.off() } + } + val options = SentryOptions() + + options.merge(externalOptions) + + assertThat(options.isSendDefaultPii).isFalse() + assertThat(options.dataCollection.isExplicitlyConfigured()).isTrue() + assertThat(options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(options.dataCollectionResolver.isDatabaseQueryData).isTrue() + assertThat(options.dataCollectionResolver.cookies).isEqualTo(KeyValueCollectionBehavior.off()) + } + @Test fun `when options is initialized, logger is not null`() { assertNotNull(SentryOptions().logger) From e4ff2f65e1744602126ddc513304db18ef3467bb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 2 Sep 2026 06:16:03 +0200 Subject: [PATCH 2/2] fix(core): Use URL query parameter external option name Align the flattened external configuration key with the Data Collection option name used by the specification and Android manifest configuration.\n\nRefs #5666\nCo-Authored-By: Claude --- sentry/src/main/java/io/sentry/ExternalOptions.java | 2 +- sentry/src/test/java/io/sentry/ExternalOptionsTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 1232536c7d8..abcb229e7e5 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -282,7 +282,7 @@ public final class ExternalOptions { } final KeyValueCollectionBehavior queryParams = - parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.query-params"); + parseKeyValueCollectionBehavior(propertiesProvider, "data-collection.url-query-params"); if (queryParams != null) { dataCollection.setUrlQueryParams(queryParams); } diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 27056593a65..b4b800d589d 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -33,7 +33,7 @@ class ExternalOptionsTest { "data-collection.http-headers.request.mode=allow_list", "data-collection.http-headers.request.terms=x-request-id,content-type", "data-collection.http-headers.response.mode=off", - "data-collection.query-params.terms=search", + "data-collection.url-query-params.terms=search", "data-collection.graphql.document=false", "data-collection.graphql.variables=true", "data-collection.database-query-data=false",