diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ed42a1c3dd..3bebc5e6d6 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 4e44ea422e..1232536c7d 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 e312fe94c4..bde387d8f0 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 fee707d31f..27056593a6 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 d5c7e6f3c7..fa3f3296fc 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)