From 55c91661bbbcbe0c27dce912a1e969e49ee1f8fc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 20 Feb 2025 17:05:53 +0100 Subject: [PATCH 001/914] chore(contributing): Update contributing guide (#4190) --- CONTRIBUTING.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 375f5cdc3ed..8e2c8b78bf1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,36 @@ To run the build and tests: make compile ``` +# Format + +To format the changed code and make CI happy you can run: + +```shell +make format +``` + +or + +```shell +./gradlew spotlessApply +``` + +# Binary compatibility validation + +To prevent breaking ABI changes and exposing things we should not, we make use of https://github.com/Kotlin/binary-compatibility-validator. If your change intended to introduce a new public method/property or modify the existing one you can overwrite the API declarations to make CI happy as follows (overwrites them from scratch): + +```shell +make api +``` + +or + +```shell +./gradlew apiDump +``` + +However, if your change did not intend to modify the public API, consider changing the method/property visibility or removing the change altogether. + # CI Build and tests are automatically run against branches and pull requests From d65359065867a6647f3661209ad06210f96796df Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 24 Feb 2025 11:18:34 +0100 Subject: [PATCH 002/914] Move to a single NetworkCallback listener to reduce number of IPC calls (#4164) * Move to a single NetworkCallback listener to reduce number of IPC calls * Update Changelog * Cleanup return handling --- CHANGELOG.md | 1 + .../util/AndroidConnectionStatusProvider.java | 110 +++++++++++------- .../AndroidConnectionStatusProviderTest.kt | 12 +- 3 files changed, 79 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9babf5adab..77dbfec961d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - The `ignoredErrors` option is now configurable via the manifest property `io.sentry.traces.ignored-errors` ([#4178](https://github.com/getsentry/sentry-java/pull/4178)) - A list of active Spring profiles is attached to payloads sent to Sentry (errors, traces, etc.) and displayed in the UI when using our Spring or Spring Boot integrations ([#4147](https://github.com/getsentry/sentry-java/pull/4147)) - This consists of an empty list when only the default profile is active +- Move to a single NetworkCallback listener to reduce number of IPC calls on Android ([#4164](https://github.com/getsentry/sentry-java/pull/4164)) ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java index 76a10567d2d..ed8948e0a5a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java @@ -4,17 +4,19 @@ import android.annotation.SuppressLint; import android.content.Context; import android.net.ConnectivityManager; +import android.net.ConnectivityManager.NetworkCallback; import android.net.Network; import android.net.NetworkCapabilities; import android.os.Build; -import androidx.annotation.NonNull; import io.sentry.IConnectionStatusProvider; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; -import java.util.HashMap; -import java.util.Map; +import io.sentry.util.AutoClosableReentrantLock; +import java.util.ArrayList; +import java.util.List; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -31,8 +33,9 @@ public final class AndroidConnectionStatusProvider implements IConnectionStatusP private final @NotNull Context context; private final @NotNull ILogger logger; private final @NotNull BuildInfoProvider buildInfoProvider; - private final @NotNull Map - registeredCallbacks; + private final @NotNull List connectionStatusObservers; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private volatile @Nullable NetworkCallback networkCallback; public AndroidConnectionStatusProvider( @NotNull Context context, @@ -41,7 +44,7 @@ public AndroidConnectionStatusProvider( this.context = ContextUtils.getApplicationContext(context); this.logger = logger; this.buildInfoProvider = buildInfoProvider; - this.registeredCallbacks = new HashMap<>(); + this.connectionStatusObservers = new ArrayList<>(); } @Override @@ -65,40 +68,64 @@ public AndroidConnectionStatusProvider( @Override public boolean addConnectionStatusObserver(final @NotNull IConnectionStatusObserver observer) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + connectionStatusObservers.add(observer); + } - final ConnectivityManager.NetworkCallback callback = - new ConnectivityManager.NetworkCallback() { - @Override - public void onAvailable(@NonNull Network network) { - observer.onConnectionStatusChanged(getConnectionStatus()); - } - - @Override - public void onLosing(@NonNull Network network, int maxMsToLive) { - observer.onConnectionStatusChanged(getConnectionStatus()); - } - - @Override - public void onLost(@NonNull Network network) { - observer.onConnectionStatusChanged(getConnectionStatus()); - } - - @Override - public void onUnavailable() { - observer.onConnectionStatusChanged(getConnectionStatus()); + if (networkCallback == null) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (networkCallback == null) { + final @NotNull NetworkCallback newNetworkCallback = + new NetworkCallback() { + @Override + public void onAvailable(final @NotNull Network network) { + updateObservers(); + } + + @Override + public void onUnavailable() { + updateObservers(); + } + + @Override + public void onLost(final @NotNull Network network) { + updateObservers(); + } + + public void updateObservers() { + final @NotNull ConnectionStatus status = getConnectionStatus(); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + for (final @NotNull IConnectionStatusObserver observer : + connectionStatusObservers) { + observer.onConnectionStatusChanged(status); + } + } + } + }; + + if (registerNetworkCallback(context, logger, buildInfoProvider, newNetworkCallback)) { + networkCallback = newNetworkCallback; + return true; + } else { + return false; } - }; - - registeredCallbacks.put(observer, callback); - return registerNetworkCallback(context, logger, buildInfoProvider, callback); + } + } + } + // networkCallback is already registered, so we can safely return true + return true; } @Override public void removeConnectionStatusObserver(final @NotNull IConnectionStatusObserver observer) { - final @Nullable ConnectivityManager.NetworkCallback callback = - registeredCallbacks.remove(observer); - if (callback != null) { - unregisterNetworkCallback(context, logger, callback); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + connectionStatusObservers.remove(observer); + if (connectionStatusObservers.isEmpty()) { + if (networkCallback != null) { + unregisterNetworkCallback(context, logger, networkCallback); + networkCallback = null; + } + } } } @@ -281,7 +308,7 @@ public static boolean registerNetworkCallback( final @NotNull Context context, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull ConnectivityManager.NetworkCallback networkCallback) { + final @NotNull NetworkCallback networkCallback) { if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { logger.log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); return false; @@ -307,7 +334,7 @@ public static boolean registerNetworkCallback( public static void unregisterNetworkCallback( final @NotNull Context context, final @NotNull ILogger logger, - final @NotNull ConnectivityManager.NetworkCallback networkCallback) { + final @NotNull NetworkCallback networkCallback) { final ConnectivityManager connectivityManager = getConnectivityManager(context, logger); if (connectivityManager == null) { @@ -322,8 +349,13 @@ public static void unregisterNetworkCallback( @TestOnly @NotNull - public Map - getRegisteredCallbacks() { - return registeredCallbacks; + public List getStatusObservers() { + return connectionStatusObservers; + } + + @TestOnly + @Nullable + public NetworkCallback getNetworkCallback() { + return networkCallback; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt index d10cdea35e1..bd4c9bd7fc4 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt @@ -24,6 +24,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -258,9 +259,12 @@ class AndroidConnectionStatusProviderTest { val observer = IConnectionStatusProvider.IConnectionStatusObserver { } val addResult = connectionStatusProvider.addConnectionStatusObserver(observer) assertTrue(addResult) + assertEquals(1, connectionStatusProvider.statusObservers.size) + assertNotNull(connectionStatusProvider.networkCallback) connectionStatusProvider.removeConnectionStatusObserver(observer) - assertTrue(connectionStatusProvider.registeredCallbacks.isEmpty()) + assertTrue(connectionStatusProvider.statusObservers.isEmpty()) + assertNull(connectionStatusProvider.networkCallback) } @Test @@ -269,18 +273,16 @@ class AndroidConnectionStatusProviderTest { var callback: NetworkCallback? = null whenever(connectivityManager.registerDefaultNetworkCallback(any())).then { invocation -> - callback = invocation.getArgument(0, NetworkCallback::class.java) + callback = invocation.getArgument(0, NetworkCallback::class.java) as NetworkCallback Unit } val observer = mock() connectionStatusProvider.addConnectionStatusObserver(observer) callback!!.onAvailable(mock()) callback!!.onUnavailable() - callback!!.onLosing(mock(), 0) callback!!.onLost(mock()) - callback!!.onUnavailable() connectionStatusProvider.removeConnectionStatusObserver(observer) - verify(observer, times(5)).onConnectionStatusChanged(any()) + verify(observer, times(3)).onConnectionStatusChanged(any()) } } From 37b98dc1a4c3d10e1eba633ecd7f2ccbec01d8e4 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 24 Feb 2025 11:35:40 +0100 Subject: [PATCH 003/914] Remove symbol collector step (#4197) As this is now done within sentry-native --- .craft.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.craft.yml b/.craft.yml index 0fcfc923601..b4944ec3372 100644 --- a/.craft.yml +++ b/.craft.yml @@ -1,10 +1,6 @@ minVersion: 0.29.3 changelogPolicy: auto targets: - - name: symbol-collector - includeNames: /libsentry(-android)?\.so/ - batchType: android - bundleIdPrefix: sentry-android-ndk- - name: maven includeNames: /^sentry.*$/ gradleCliPath: ./gradlew From c87d42930f782ec066b1a2be764d9413eb8b9501 Mon Sep 17 00:00:00 2001 From: Lorenzo Cian Date: Mon, 24 Feb 2025 13:34:57 +0100 Subject: [PATCH 004/914] Add GraphQL Apollo Kotlin 4 integration (#4166) * Apollo 4 initial setup * rename some stuff, change and add a few tests * parametrize tests to run with both v3 and v4 implementations of `ApolloCall::execute` * changelog * rename SentryApollo4BuilderExtensions back to SentryApolloBuilderExtensions to make it easier to migrate * make api * add README.md * update comment --------- Co-authored-by: Lukas Kusik --- CHANGELOG.md | 1 + buildSrc/src/main/java/Config.kt | 2 + sentry-apollo-4/README.md | 5 + sentry-apollo-4/api/sentry-apollo-4.api | 50 ++ sentry-apollo-4/build.gradle.kts | 89 ++++ .../java/io/sentry/apollo4/SentryApollo4.kt | 17 + .../apollo4/SentryApollo4ClientException.kt | 11 + .../apollo4/SentryApollo4HttpInterceptor.kt | 453 ++++++++++++++++++ .../apollo4/SentryApollo4Interceptor.kt | 56 +++ .../apollo4/SentryApolloBuilderExtensions.kt | 39 ++ ...pollo4BuilderExtensionsClientErrorsTest.kt | 399 +++++++++++++++ .../SentryApollo4BuilderExtensionsTest.kt | 220 +++++++++ .../SentryApollo4HttpInterceptorTest.kt | 388 +++++++++++++++ .../apollo4/generated/LaunchDetailsQuery.kt | 89 ++++ .../LaunchDetailsQuery_ResponseAdapter.kt | 166 +++++++ .../LaunchDetailsQuery_VariablesAdapter.kt | 27 ++ .../LaunchDetailsQuerySelections.kt | 82 ++++ .../apollo4/generated/type/GraphQLBoolean.kt | 17 + .../apollo4/generated/type/GraphQLID.kt | 20 + .../apollo4/generated/type/GraphQLString.kt | 18 + .../sentry/apollo4/generated/type/Launch.kt | 14 + .../sentry/apollo4/generated/type/Mission.kt | 14 + .../io/sentry/apollo4/generated/type/Query.kt | 14 + .../sentry/apollo4/generated/type/Rocket.kt | 14 + .../util/Apollo4PlatformTestManipulator.kt | 8 + .../org.mockito.plugins.MockMaker | 1 + settings.gradle.kts | 1 + 27 files changed, 2215 insertions(+) create mode 100644 sentry-apollo-4/README.md create mode 100644 sentry-apollo-4/api/sentry-apollo-4.api create mode 100644 sentry-apollo-4/build.gradle.kts create mode 100644 sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4.kt create mode 100644 sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4ClientException.kt create mode 100644 sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt create mode 100644 sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt create mode 100644 sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/LaunchDetailsQuery.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_ResponseAdapter.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_VariablesAdapter.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/selections/LaunchDetailsQuerySelections.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLBoolean.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLID.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLString.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Launch.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Mission.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Query.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Rocket.kt create mode 100644 sentry-apollo-4/src/test/java/io/sentry/util/Apollo4PlatformTestManipulator.kt create mode 100644 sentry-apollo-4/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/CHANGELOG.md b/CHANGELOG.md index 77dbfec961d..f81d76ab606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - A list of active Spring profiles is attached to payloads sent to Sentry (errors, traces, etc.) and displayed in the UI when using our Spring or Spring Boot integrations ([#4147](https://github.com/getsentry/sentry-java/pull/4147)) - This consists of an empty list when only the default profile is active - Move to a single NetworkCallback listener to reduce number of IPC calls on Android ([#4164](https://github.com/getsentry/sentry-java/pull/4164)) +- Add GraphQL Apollo Kotlin 4 integration ([#4166](https://github.com/getsentry/sentry-java/pull/4166)) ### Fixes diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index ac379bcb41d..0a3c62a1555 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -157,6 +157,7 @@ object Config { val composeCoil = "io.coil-kt:coil-compose:2.6.0" val apolloKotlin = "com.apollographql.apollo3:apollo-runtime:3.8.2" + val apolloKotlin4 = "com.apollographql.apollo:apollo-runtime:4.1.1" val sentryNativeNdk = "io.sentry:sentry-native-ndk:0.7.20" @@ -250,6 +251,7 @@ object Config { val SENTRY_SPRING_BOOT_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot.jakarta" val SENTRY_OPENTELEMETRY_AGENT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.agent" val SENTRY_APOLLO3_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo3" + val SENTRY_APOLLO4_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo4" val SENTRY_APOLLO_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo" val SENTRY_GRAPHQL_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql" val SENTRY_GRAPHQL22_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql22" diff --git a/sentry-apollo-4/README.md b/sentry-apollo-4/README.md new file mode 100644 index 00000000000..e9b4ad2efe5 --- /dev/null +++ b/sentry-apollo-4/README.md @@ -0,0 +1,5 @@ +# sentry-apollo-4 + +This module provides an integration for [Apollo Kotlin 4](https://www.apollographql.com/docs/kotlin/v4). + +Please consult the documentation on how to install and use this integration in the Sentry Docs for [Android](https://docs.sentry.io/platforms/android/integrations/apollo4/) or [Java](https://docs.sentry.io/platforms/java/tracing/instrumentation/apollo4/). diff --git a/sentry-apollo-4/api/sentry-apollo-4.api b/sentry-apollo-4/api/sentry-apollo-4.api new file mode 100644 index 00000000000..ec7f6ff0512 --- /dev/null +++ b/sentry-apollo-4/api/sentry-apollo-4.api @@ -0,0 +1,50 @@ +public final class io/sentry/apollo4/BuildConfig { + public static final field SENTRY_APOLLO4_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/apollo4/SentryApollo4ClientException : java/lang/Exception { + public static final field Companion Lio/sentry/apollo4/SentryApollo4ClientException$Companion; + public fun (Ljava/lang/String;)V +} + +public final class io/sentry/apollo4/SentryApollo4ClientException$Companion { +} + +public final class io/sentry/apollo4/SentryApollo4HttpInterceptor : com/apollographql/apollo/network/http/HttpInterceptor { + public static final field Companion Lio/sentry/apollo4/SentryApollo4HttpInterceptor$Companion; + public static final field DEFAULT_CAPTURE_FAILED_REQUESTS Z + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;Z)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;)V + public synthetic fun (Lio/sentry/IScopes;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun intercept (Lcom/apollographql/apollo/api/http/HttpRequest;Lcom/apollographql/apollo/network/http/HttpInterceptorChain;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class io/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback { + public abstract fun execute (Lio/sentry/ISpan;Lcom/apollographql/apollo/api/http/HttpRequest;Lcom/apollographql/apollo/api/http/HttpResponse;)Lio/sentry/ISpan; +} + +public final class io/sentry/apollo4/SentryApollo4HttpInterceptor$Companion { +} + +public final class io/sentry/apollo4/SentryApollo4Interceptor : com/apollographql/apollo/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/apollo/api/ApolloRequest;Lcom/apollographql/apollo/interceptor/ApolloInterceptorChain;)Lkotlinx/coroutines/flow/Flow; +} + +public final class io/sentry/apollo4/SentryApolloBuilderExtensionsKt { + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;Lio/sentry/IScopes;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;Lio/sentry/IScopes;Z)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;Lio/sentry/IScopes;ZLjava/util/List;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;Lio/sentry/IScopes;ZLjava/util/List;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo/ApolloClient$Builder;ZLjava/util/List;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static synthetic fun sentryTracing$default (Lcom/apollographql/apollo/ApolloClient$Builder;Lio/sentry/IScopes;ZLjava/util/List;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;ILjava/lang/Object;)Lcom/apollographql/apollo/ApolloClient$Builder; + public static synthetic fun sentryTracing$default (Lcom/apollographql/apollo/ApolloClient$Builder;ZLjava/util/List;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;ILjava/lang/Object;)Lcom/apollographql/apollo/ApolloClient$Builder; +} + diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts new file mode 100644 index 00000000000..6e8c292966b --- /dev/null +++ b/sentry-apollo-4/build.gradle.kts @@ -0,0 +1,89 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") + jacoco + id(Config.QualityPlugins.errorProne) + id(Config.QualityPlugins.gradleVersions) + id(Config.BuildPlugins.buildConfig) version Config.BuildPlugins.buildConfigVersion +} + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = Config.kotlinCompatibleLanguageVersion +} + +dependencies { + api(projects.sentry) + api(projects.sentryKotlinExtensions) + + compileOnly(Config.Libs.apolloKotlin4) + + compileOnly(Config.CompileOnly.nopen) + errorprone(Config.CompileOnly.nopenChecker) + errorprone(Config.CompileOnly.errorprone) + errorprone(Config.CompileOnly.errorProneNullAway) + compileOnly(Config.CompileOnly.jetbrainsAnnotations) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(Config.Libs.coroutinesCore) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) + testImplementation(Config.TestLibs.mockWebserver) + testImplementation(Config.Libs.apolloKotlin4) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0") +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +jacoco { + toolVersion = Config.QualityPlugins.Jacoco.version +} + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { + rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } + } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.apollo4") + buildConfigField("String", "SENTRY_APOLLO4_SDK_NAME", "\"${Config.Sentry.SENTRY_APOLLO4_SDK_NAME}\"") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4.kt new file mode 100644 index 00000000000..dd599c5e6e9 --- /dev/null +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4.kt @@ -0,0 +1,17 @@ +package io.sentry.apollo4 + +/** + * Common constants used across the module + */ +internal const val OPERATION_ID_HEADER_NAME = "SENTRY-APOLLO-4-OPERATION-ID" +internal const val OPERATION_NAME_HEADER_NAME = "SENTRY-APOLLO-4-OPERATION-NAME" +internal const val OPERATION_TYPE_HEADER_NAME = "SENTRY-APOLLO-4-OPERATION-TYPE" +internal const val VARIABLES_HEADER_NAME = "SENTRY-APOLLO-4-VARIABLES" +internal val INTERNAL_HEADER_NAMES by lazy { + listOf( + OPERATION_ID_HEADER_NAME, + OPERATION_NAME_HEADER_NAME, + OPERATION_TYPE_HEADER_NAME, + VARIABLES_HEADER_NAME + ) +} diff --git a/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4ClientException.kt b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4ClientException.kt new file mode 100644 index 00000000000..11f6440dc8a --- /dev/null +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4ClientException.kt @@ -0,0 +1,11 @@ +package io.sentry.apollo4 + +/** + * Used for holding an Apollo4 client error, for example. An integration that does not throw when API + * returns 4xx, 5xx or the `errors` field. + */ +class SentryApollo4ClientException(message: String?) : Exception(message) { + companion object { + private const val serialVersionUID = 4312160066430858144L + } +} 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 new file mode 100644 index 00000000000..a4e31431bb6 --- /dev/null +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4HttpInterceptor.kt @@ -0,0 +1,453 @@ +package io.sentry.apollo4 + +import com.apollographql.apollo.api.http.HttpHeader +import com.apollographql.apollo.api.http.HttpRequest +import com.apollographql.apollo.api.http.HttpResponse +import com.apollographql.apollo.exception.ApolloHttpException +import com.apollographql.apollo.network.http.HttpInterceptor +import com.apollographql.apollo.network.http.HttpInterceptorChain +import io.sentry.BaggageHeader +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ScopesAdapter +import io.sentry.SentryEvent +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel +import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.SpanDataConvention +import io.sentry.SpanDataConvention.HTTP_METHOD_KEY +import io.sentry.SpanStatus +import io.sentry.TypeCheckHint.APOLLO_REQUEST +import io.sentry.TypeCheckHint.APOLLO_RESPONSE +import io.sentry.exception.ExceptionMechanismException +import io.sentry.protocol.Mechanism +import io.sentry.protocol.Request +import io.sentry.protocol.Response +import io.sentry.util.HttpUtils +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import io.sentry.util.Platform +import io.sentry.util.PropagationTargetsUtils +import io.sentry.util.SpanUtils +import io.sentry.util.TracingUtils +import io.sentry.util.UrlUtils +import io.sentry.vendor.Base64 +import okio.Buffer +import org.jetbrains.annotations.ApiStatus +import java.util.Locale + +private const val TRACE_ORIGIN = "auto.graphql.apollo4" + +class SentryApollo4HttpInterceptor @JvmOverloads constructor( + @ApiStatus.Internal private val scopes: IScopes = ScopesAdapter.getInstance(), + private val beforeSpan: BeforeSpanCallback? = null, + private val captureFailedRequests: Boolean = DEFAULT_CAPTURE_FAILED_REQUESTS, + private val failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS) +) : HttpInterceptor { + + init { + addIntegrationToSdkVersion("Apollo4") + if (captureFailedRequests) { + SentryIntegrationPackageStorage.getInstance() + .addIntegration("Apollo4ClientError") + } + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-apollo-4", BuildConfig.VERSION_NAME) + } + + private val regex: Regex by lazy { + "(?i)\"errors\"\\s*:\\s*\\[".toRegex() + } + + override suspend fun intercept( + request: HttpRequest, + chain: HttpInterceptorChain + ): HttpResponse { + val activeSpan = if (Platform.isAndroid()) scopes.transaction else scopes.span + + val operationId = decodeHeaderValue(request, OPERATION_ID_HEADER_NAME) + val operationName = decodeHeaderValue(request, OPERATION_NAME_HEADER_NAME) + val operationType = decodeHeaderValue(request, OPERATION_TYPE_HEADER_NAME) + + var span: ISpan? = null + + if (activeSpan != null) { + span = startChild(request, activeSpan, operationName, operationType, operationId) + } + + val modifiedRequest = maybeAddTracingHeaders(scopes, request, span) + var httpResponse: HttpResponse? = null + var statusCode: Int? = null + + try { + httpResponse = chain.proceed(modifiedRequest) + statusCode = httpResponse.statusCode + span?.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode) + span?.status = SpanStatus.fromHttpStatusCode(statusCode) + + captureEvent(modifiedRequest, httpResponse, operationName, operationType) + + return httpResponse + } catch (e: Throwable) { + // client errors don't throw anymore in v4, but we should still be able to detect all of them by looking at the status code and/or errors in the response body + when (e) { + is ApolloHttpException -> { + statusCode = e.statusCode + span?.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode) + span?.status = + SpanStatus.fromHttpStatusCode(statusCode, SpanStatus.INTERNAL_ERROR) + } + + else -> span?.status = SpanStatus.INTERNAL_ERROR + } + span?.throwable = e + throw e + } finally { + finish( + span, + modifiedRequest, + httpResponse, + statusCode, + operationName, + operationType, + operationId + ) + } + } + + private fun maybeAddTracingHeaders(scopes: IScopes, request: HttpRequest, span: ISpan?): HttpRequest { + var cleanedHeaders = removeSentryInternalHeaders(request.headers).toMutableList() + + if (!isIgnored()) { + TracingUtils.traceIfAllowed(scopes, request.url, request.headers.filter { it.name == BaggageHeader.BAGGAGE_HEADER }.map { it.value }, span)?.let { + cleanedHeaders.add(HttpHeader(it.sentryTraceHeader.name, it.sentryTraceHeader.value)) + it.baggageHeader?.let { baggageHeader -> + cleanedHeaders = cleanedHeaders.filterNot { it.name == BaggageHeader.BAGGAGE_HEADER }.toMutableList().apply { + add(HttpHeader(baggageHeader.name, baggageHeader.value)) + } + } + } + } + + val requestBuilder = request.newBuilder().apply { + headers(cleanedHeaders) + } + + return requestBuilder.build() + } + + private fun isIgnored(): Boolean { + return SpanUtils.isIgnored(scopes.getOptions().ignoredSpanOrigins, TRACE_ORIGIN) + } + + private fun removeSentryInternalHeaders(headers: List): List { + return headers.filterNot { header -> + INTERNAL_HEADER_NAMES.any { internalHeader -> header.name.equals(internalHeader, true) } + } + } + + private fun startChild( + request: HttpRequest, + activeSpan: ISpan, + operationName: String?, + operationType: String?, + operationId: String? + ): ISpan { + val urlDetails = UrlUtils.parse(request.url) + val method = request.method.name + + val operation = if (operationType != null) "http.graphql.$operationType" else "http.graphql" + val variables = decodeHeaderValue(request, VARIABLES_HEADER_NAME) + + val description = "${operationType ?: method} ${operationName ?: urlDetails.urlOrFallback}" + + return activeSpan.startChild(operation, description).apply { + urlDetails.applyToSpan(this) + + spanContext.origin = TRACE_ORIGIN + + operationId?.let { + setData("operationId", it) + } + + variables?.let { + setData("variables", it) + } + setData(HTTP_METHOD_KEY, method.uppercase(Locale.ROOT)) + } + } + + private fun decodeHeaderValue(request: HttpRequest, headerName: String): String? { + return getHeader(headerName, request.headers)?.let { + try { + String(Base64.decode(it, Base64.NO_WRAP)) + } catch (e: Throwable) { + scopes.options.logger.log( + SentryLevel.ERROR, + "Error decoding internal apolloHeader $headerName", + e + ) + return null + } + } + } + + private fun finish( + span: ISpan?, + request: HttpRequest, + response: HttpResponse?, + statusCode: Int?, + operationName: String?, + operationType: String?, + operationId: String? + ) { + var responseContentLength: Long? = null + response?.body?.buffer?.size?.ifHasValidLength { + responseContentLength = it + } + + if (span != null) { + statusCode?.let { + span.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode) + } + responseContentLength?.let { + span.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, it) + } + if (beforeSpan != null) { + try { + val result = beforeSpan.execute(span, request, response) + if (result == null) { + // Span is dropped + span.spanContext.sampled = false + } + } catch (e: Throwable) { + scopes.options.logger.log( + SentryLevel.ERROR, + "An error occurred while executing beforeSpan in ApolloInterceptor", + e + ) + } + } + span.finish() + } + + val breadcrumb = Breadcrumb.http(request.url, request.method.name, statusCode) + + request.body?.contentLength.ifHasValidLength { contentLength -> + breadcrumb.setData("request_body_size", contentLength) + } + + operationName?.let { + breadcrumb.setData("operation_name", it) + } + operationType?.let { + breadcrumb.setData("operation_type", it) + } + operationId?.let { + breadcrumb.setData("operation_id", it) + } + + val hint = Hint().also { + it.set(APOLLO_REQUEST, request) + } + + response?.let { httpResponse -> + responseContentLength?.let { + breadcrumb.setData("response_body_size", it) + } + + hint.set(APOLLO_RESPONSE, httpResponse) + } + + scopes.addBreadcrumb(breadcrumb, hint) + } + + // Extensions + + private fun Long?.ifHasValidLength(fn: (Long) -> Unit) { + if (this != null && this != -1L) { + fn.invoke(this) + } + } + + private fun getHeader(key: String, headers: List): String? { + return headers.firstOrNull { it.name.equals(key, true) }?.value + } + + private fun getHeaders(headers: List): MutableMap? { + // Headers are only sent if isSendDefaultPii is enabled due to PII + if (!scopes.options.isSendDefaultPii) { + return null + } + + val headersMap = mutableMapOf() + + for (item in headers) { + val name = item.name + + // header is only sent if isn't sensitive + if (HttpUtils.containsSensitiveHeader(name)) { + continue + } + + headersMap[name] = item.value + } + return headersMap.ifEmpty { null } + } + + private fun captureEvent( + request: HttpRequest, + response: HttpResponse, + operationName: String?, + operationType: String? + ) { + // return if the feature is disabled + if (!captureFailedRequests) { + return + } + + // wrap everything up in a try catch block so every exception is swallowed and degraded + // gracefully + try { + // we pay the price to read the response in the memory to check if there's any errors + // GraphQL does not throw status code 400+ for every type of error + val body = try { + response.body?.peek()?.readUtf8() ?: "" + } catch (e: Throwable) { + scopes.options.logger.log( + SentryLevel.ERROR, + "Error reading the response body.", + e + ) + // bail out because the response body has the most important information + return + } + + // if the response body does not have the errors field, do not raise an issue + if (body.isEmpty() || !regex.containsMatchIn(body)) { + return + } + + // not possible to get a parameterized url, but we remove at least the + // query string and the fragment. + // url example: https://api.github.com/users/getsentry/repos/#fragment?query=query + // 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) + + // return if it's not a target match + if (!PropagationTargetsUtils.contain(failedRequestTargets, urlDetails.urlOrFallback)) { + return + } + + val mechanism = Mechanism().apply { + type = "SentryApollo4Interceptor" + } + + val fingerprints = mutableListOf() + + val builder = StringBuilder() + builder.append("GraphQL Request failed") + operationName?.let { + builder.append(", name: $it") + fingerprints.add(operationName) + } + operationType?.let { + builder.append(", type: $it") + fingerprints.add(operationType) + } + + val exception = SentryApollo4ClientException(builder.toString()) + val mechanismException = + ExceptionMechanismException(mechanism, exception, Thread.currentThread(), true) + val event = SentryEvent(mechanismException) + + val hint = Hint() + hint.set(APOLLO_REQUEST, request) + hint.set(APOLLO_RESPONSE, response) + + val sentryRequest = Request().apply { + urlDetails.applyToRequest(this) + // Cookie is only sent if isSendDefaultPii is enabled + cookies = + if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null + method = request.method.name + headers = getHeaders(request.headers) + apiTarget = "graphql" + + request.body?.let { + bodySize = it.contentLength + + val buffer = Buffer() + + try { + it.writeTo(buffer) + data = buffer.readUtf8() + } 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() + } + } + } + + val sentryResponse = Response().apply { + // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII + cookies = if (scopes.options.isSendDefaultPii) { + getHeader( + "Set-Cookie", + response.headers + ) + } else { + null + } + headers = getHeaders(response.headers) + statusCode = response.statusCode + + response.body?.buffer?.size?.ifHasValidLength { contentLength -> + bodySize = contentLength + } + data = body + } + + fingerprints.add(response.statusCode.toString()) + + event.request = sentryRequest + event.contexts.setResponse(sentryResponse) + event.fingerprints = fingerprints + + scopes.captureEvent(event, hint) + } catch (e: Throwable) { + scopes.options.logger.log( + SentryLevel.ERROR, + "Error capturing the GraphQL error.", + e + ) + } + } + + /** + * The BeforeSpan callback + */ + fun interface BeforeSpanCallback { + /** + * Mutates span before being added. + * + * @param span the span to mutate or drop + * @param request the Apollo request object + * @param response the Apollo response object + */ + fun execute(span: ISpan, request: HttpRequest, response: HttpResponse?): ISpan? + } + + companion object { + const val DEFAULT_CAPTURE_FAILED_REQUESTS = true + } +} 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 new file mode 100644 index 00000000000..5a57eccefc1 --- /dev/null +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApollo4Interceptor.kt @@ -0,0 +1,56 @@ +package io.sentry.apollo4 + +import com.apollographql.apollo.api.ApolloRequest +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.Mutation +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.api.Query +import com.apollographql.apollo.api.Subscription +import com.apollographql.apollo.api.variables +import com.apollographql.apollo.interceptor.ApolloInterceptor +import com.apollographql.apollo.interceptor.ApolloInterceptorChain +import io.sentry.IScopes +import io.sentry.ScopesAdapter +import io.sentry.vendor.Base64 +import kotlinx.coroutines.flow.Flow +import org.jetbrains.annotations.ApiStatus + +/** + * Interceptor that adds the GraphQL request information to the outgoing HTTP request's headers so that + * the information can be accessed by {@link SentryApollo4HttpInterceptor} + */ +class SentryApollo4Interceptor @JvmOverloads constructor( + @ApiStatus.Internal private val scopes: IScopes = ScopesAdapter.getInstance() +) : ApolloInterceptor { + + override fun intercept( + request: ApolloRequest, + chain: ApolloInterceptorChain + ): Flow> { + val builder = request.newBuilder() + .addHttpHeader(OPERATION_ID_HEADER_NAME, encodeHeaderValue(request.operation.id())) + .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())) + } + + return chain.proceed(builder.build()) + } +} + +private fun encodeHeaderValue(value: String): String { + return Base64.encodeToString(value.toByteArray(), Base64.NO_WRAP) +} + +private fun operationType(apolloRequest: ApolloRequest) = when (apolloRequest.operation) { + is Query -> "query" + is Mutation -> "mutation" + is Subscription -> "subscription" + else -> apolloRequest.operation.javaClass.simpleName +} + +private val ApolloRequest.scalarAdapters + get() = executionContext[CustomScalarAdapters] 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 new file mode 100644 index 00000000000..a0e07225d17 --- /dev/null +++ b/sentry-apollo-4/src/main/java/io/sentry/apollo4/SentryApolloBuilderExtensions.kt @@ -0,0 +1,39 @@ +package io.sentry.apollo4 + +import com.apollographql.apollo.ApolloClient +import io.sentry.IScopes +import io.sentry.ScopesAdapter +import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.apollo4.SentryApollo4HttpInterceptor.Companion.DEFAULT_CAPTURE_FAILED_REQUESTS + +@JvmOverloads +fun ApolloClient.Builder.sentryTracing( + scopes: IScopes = ScopesAdapter.getInstance(), + captureFailedRequests: Boolean = DEFAULT_CAPTURE_FAILED_REQUESTS, + failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), + beforeSpan: SentryApollo4HttpInterceptor.BeforeSpanCallback? = null +): ApolloClient.Builder { + addInterceptor(SentryApollo4Interceptor()) + addHttpInterceptor( + SentryApollo4HttpInterceptor( + scopes = scopes, + captureFailedRequests = captureFailedRequests, + failedRequestTargets = failedRequestTargets, + beforeSpan = beforeSpan + ) + ) + return this +} + +fun ApolloClient.Builder.sentryTracing( + captureFailedRequests: Boolean = DEFAULT_CAPTURE_FAILED_REQUESTS, + failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), + beforeSpan: SentryApollo4HttpInterceptor.BeforeSpanCallback? = null +): ApolloClient.Builder { + return sentryTracing( + scopes = ScopesAdapter.getInstance(), + captureFailedRequests = captureFailedRequests, + failedRequestTargets = failedRequestTargets, + beforeSpan = beforeSpan + ) +} 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 new file mode 100644 index 00000000000..d7df80cb038 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsClientErrorsTest.kt @@ -0,0 +1,399 @@ +package io.sentry.apollo4 + +import com.apollographql.apollo.ApolloCall +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Operation +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.IScopes +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.TypeCheckHint +import io.sentry.apollo4.SentryApollo4HttpInterceptor.Companion.DEFAULT_CAPTURE_FAILED_REQUESTS +import io.sentry.apollo4.generated.LaunchDetailsQuery +import io.sentry.exception.ExceptionMechanismException +import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryId +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.mockito.kotlin.any +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.reflect.KSuspendFunction1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SentryApollo4BuilderExtensionsClientErrorsTestWithV4Implementation : SentryApollo4BuilderExtensionsClientErrorsTest(ApolloCall<*>::execute) +class SentryApollo4BuilderExtensionsClientErrorsTestWithV3Implementation : SentryApollo4BuilderExtensionsClientErrorsTest(ApolloCall<*>::executeV3) + +abstract class SentryApollo4BuilderExtensionsClientErrorsTest( + private val executeQueryImplementation: KSuspendFunction1, ApolloResponse> +) { + class Fixture { + val server = MockWebServer() + lateinit var scopes: IScopes + + private val responseBodyOk = + """{ + "data": { + "launch": { + "__typename": "Launch", + "id": "83", + "site": "CCAFS SLC 40", + "mission": { + "__typename": "Mission", + "name": "Amos-17", + "missionPatch": "https://images2.imgbox.com/a0/ab/XUoByiuR_o.png" + } + } + } +}""" + + val responseBodyNotOk = + """{ + "errors": [ + { + "message": "Cannot query field \"mySite\" on type \"Launch\". Did you mean \"site\"?", + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED" + } + } + ] +}""" + + fun getSut( + captureFailedRequests: Boolean = DEFAULT_CAPTURE_FAILED_REQUESTS, + failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), + httpStatusCode: Int = 200, + responseBody: String = responseBodyOk, + sendDefaultPii: Boolean = false, + socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN + ): ApolloClient { + SentryIntegrationPackageStorage.getInstance().clearStorage() + + scopes = mock().apply { + whenever(options).thenReturn( + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + sdkVersion = SdkVersion("test", "1.2.3") + isSendDefaultPii = sendDefaultPii + } + ) + } + whenever(scopes.captureEvent(any(), any())).thenReturn(SentryId.EMPTY_ID) + + val response = MockResponse() + .setBody(responseBody) + .setSocketPolicy(socketPolicy) + .setResponseCode(httpStatusCode) + + if (sendDefaultPii) { + response.addHeader("Set-Cookie", "Test") + } + + server.enqueue( + response + ) + + val builder = ApolloClient.Builder() + .serverUrl(server.url("?myQuery=query#myFragment").toString()) + .sentryTracing( + scopes = scopes, + captureFailedRequests = captureFailedRequests, + failedRequestTargets = failedRequestTargets + ) + if (sendDefaultPii) { + builder.addHttpHeader("Cookie", "Test") + } + + return builder.build() + } + } + + private val fixture = Fixture() + + // region captureFailedRequests + + @Test + fun `does not capture errors if captureFailedRequests is disabled`() { + val sut = fixture.getSut(captureFailedRequests = false, responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `capture errors if captureFailedRequests is enabled`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent(any(), any()) + } + + // endregion + + // region Apollo4ClientError + + @Test + fun `does not add Apollo4ClientError integration if captureFailedRequests is disabled`() { + fixture.getSut(captureFailedRequests = false) + + assertFalse(SentryIntegrationPackageStorage.getInstance().integrations.contains("Apollo4ClientError")) + } + + @Test + fun `adds Apollo4ClientError integration if captureFailedRequests is enabled`() { + fixture.getSut() + + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("Apollo4ClientError")) + } + + // endregion + + // region failedRequestTargets + + @Test + fun `does not capture errors if failedRequestTargets does not match`() { + val sut = fixture.getSut( + failedRequestTargets = listOf("nope.com"), + responseBody = fixture.responseBodyNotOk + ) + executeQuery(sut) + + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `capture errors if failedRequestTargets matches`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent(any(), any()) + } + + // endregion + + // region SentryEvent + + @Test + fun `capture errors with SentryApollo4Interceptor mechanism`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent( + check { + val throwable = (it.throwableMechanism as ExceptionMechanismException) + assertEquals("SentryApollo4Interceptor", throwable.exceptionMechanism.type) + }, + any() + ) + } + + @Test + fun `capture errors with title`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent( + check { + val throwable = (it.throwableMechanism as ExceptionMechanismException) + assertEquals("GraphQL Request failed, name: LaunchDetails, type: query", throwable.throwable.message) + }, + any() + ) + } + + @Test + fun `capture errors with snapshot flag set`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent( + check { + val throwable = (it.throwableMechanism as ExceptionMechanismException) + assertTrue(throwable.isSnapshot) + }, + any() + ) + } + + private val escapeDolar = "\$id" + + @Test + fun `capture errors with request context`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + val body = + """ +{"operationName":"LaunchDetails","variables":{"id":"83"},"query":"query LaunchDetails($escapeDolar: ID!) { launch(id: $escapeDolar) { id site mission { name missionPatch(size: LARGE) } rocket { name type } } }"} + """.trimIndent() + + verify(fixture.scopes).captureEvent( + check { + val request = it.request!! + + assertEquals("http://localhost:${fixture.server.port}/", request.url) + assertEquals("myQuery=query", request.queryString) + assertEquals("myFragment", request.fragment) + assertEquals("Post", request.method) + assertEquals("graphql", request.apiTarget) + assertEquals(193L, request.bodySize) + assertEquals(body, request.data) + assertNull(request.cookies) + assertNull(request.headers) + }, + any() + ) + } + + @Test + fun `capture errors with more request context if sendDefaultPii is enabled`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) + executeQuery(sut) + + verify(fixture.scopes).captureEvent( + check { + val request = it.request!! + + assertEquals("Test", request.cookies) + assertNotNull(request.headers) + }, + any() + ) + } + + @Test + fun `capture errors with response context`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent( + check { + val response = it.contexts.response!! + + assertEquals(200, response.statusCode) + assertEquals(200, response.bodySize) + assertEquals(fixture.responseBodyNotOk, response.data) + assertNull(response.cookies) + assertNull(response.headers) + }, + any() + ) + } + + @Test + fun `capture errors with more response context if sendDefaultPii is enabled`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) + executeQuery(sut) + + verify(fixture.scopes).captureEvent( + check { + val response = it.contexts.response!! + + assertEquals("Test", response.cookies) + assertNotNull(response.headers) + assertEquals(200, response.headers?.get("Content-Length")?.toInt()) + }, + any() + ) + } + + @Test + fun `capture errors with specific fingerprints`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent( + check { + assertEquals(listOf("LaunchDetails", "query", "200"), it.fingerprints) + }, + any() + ) + } + + // endregion + + // region errors + + @Test + fun `capture errors if response code is equal or higher than 400`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk, httpStatusCode = 500) + executeQuery(sut) + + // HttpInterceptor does not throw for >= 400 + verify(fixture.scopes).captureEvent(any(), any()) + } + + @Test + fun `capture errors swallow any exception during the error transformation`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + + whenever(fixture.scopes.captureEvent(any(), any())).thenThrow(RuntimeException()) + + executeQuery(sut) + } + + // endregion + + // region hints + + @Test + fun `hints are set when capturing errors`() { + val sut = + fixture.getSut(responseBody = fixture.responseBodyNotOk) + executeQuery(sut) + + verify(fixture.scopes).captureEvent( + any(), + check { + val request = it.get(TypeCheckHint.APOLLO_REQUEST) + assertNotNull(request) + assertTrue(request is HttpRequest) + + val response = it.get(TypeCheckHint.APOLLO_RESPONSE) + assertNotNull(response) + assertTrue(response is HttpResponse) + } + ) + } + + // endregion + + private fun executeQuery(sut: ApolloClient, id: String = "83") = runBlocking { + val coroutine = launch { + try { + executeQueryImplementation(sut.query(LaunchDetailsQuery(id))) + } catch (e: ApolloException) { + return@launch + } + } + + coroutine.join() + } +} 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 new file mode 100644 index 00000000000..5098e241af2 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4BuilderExtensionsTest.kt @@ -0,0 +1,220 @@ +package io.sentry.apollo4 + +import com.apollographql.apollo.ApolloCall +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.exception.ApolloException +import io.sentry.Breadcrumb +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TraceContext +import io.sentry.TracesSamplingDecision +import io.sentry.TransactionContext +import io.sentry.apollo4.SentryApollo4HttpInterceptor.BeforeSpanCallback +import io.sentry.apollo4.generated.LaunchDetailsQuery +import io.sentry.mockServerRequestTimeoutMillis +import io.sentry.protocol.SentryTransaction +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.concurrent.TimeUnit +import kotlin.reflect.KSuspendFunction1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SentryApollo4BuilderExtensionsTestWithV4Implementation : SentryApollo4BuilderExtensionsTest(ApolloCall<*>::execute) +class SentryApollo4BuilderExtensionsTestWithV3Implementation : SentryApollo4BuilderExtensionsTest(ApolloCall<*>::executeV3) + +abstract class SentryApollo4BuilderExtensionsTest( + private val executeQueryImplementation: KSuspendFunction1, ApolloResponse> +) { + + class Fixture { + val server = MockWebServer() + val scopes = mock() + + @SuppressWarnings("LongParameterList") + fun getSut( + httpStatusCode: Int = 200, + responseBody: String = """{ + "data": { + "launch": { + "__typename": "Launch", + "id": "83", + "site": "CCAFS SLC 40", + "mission": { + "__typename": "Mission", + "name": "Amos-17", + "missionPatch": "https://images2.imgbox.com/a0/ab/XUoByiuR_o.png" + } + } + } +}""", + socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + beforeSpan: BeforeSpanCallback? = null + ): ApolloClient { + whenever(scopes.options).thenReturn( + SentryOptions().apply { + dsn = "http://key@localhost/proj" + } + ) + + server.enqueue( + MockResponse() + .setBody(responseBody) + .setSocketPolicy(socketPolicy) + .setResponseCode(httpStatusCode) + ) + + return ApolloClient.Builder().serverUrl(server.url("/").toString()) + .sentryTracing(scopes = scopes, beforeSpan = beforeSpan, captureFailedRequests = false) + .build() + } + } + + private val fixture = Fixture() + + @Test + fun `creates span around successful request`() { + executeQuery() + + verify(fixture.scopes).captureTransaction( + check { + assertTransactionDetails(it) + assertEquals(SpanStatus.OK, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `creates span around failed request`() { + executeQuery(fixture.getSut(httpStatusCode = 403)) + + verify(fixture.scopes).captureTransaction( + check { + assertTransactionDetails(it) + assertEquals(SpanStatus.PERMISSION_DENIED, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `creates span around request failing with network error`() { + executeQuery(fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) + + verify(fixture.scopes).captureTransaction( + check { + assertTransactionDetails(it) + assertEquals(SpanStatus.INTERNAL_ERROR, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `adds breadcrumb when http call succeeds`() { + executeQuery(fixture.getSut()) + + verify(fixture.scopes).addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(200, it.data["status_code"]) + // response_body_size is added but mock webserver returns 0 always + assertEquals(0L, it.data["response_body_size"]) + assertEquals(193L, it.data["request_body_size"]) + assertEquals("query", it.data["operation_type"]) + }, + anyOrNull() + ) + } + + @Test + fun `adds breadcrumb when http call fails`() { + executeQuery(fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) + + verify(fixture.scopes).addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(193L, it.data["request_body_size"]) + assertEquals("query", it.data["operation_type"]) + }, + anyOrNull() + ) + } + + @Test + fun `handles non-ascii header values correctly`() { + executeQuery(id = "á") + + verify(fixture.scopes).captureTransaction( + check { + assertTransactionDetails(it) + assertEquals(SpanStatus.OK, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `does not send internal headers over the wire`() { + executeQuery(fixture.getSut()) + val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + for (sentryHeader in INTERNAL_HEADER_NAMES) { + assertTrue(recordedRequest.headers.none { header -> header.first.equals(sentryHeader, true) }) + } + } + + private fun assertTransactionDetails(it: SentryTransaction) { + assertEquals(1, it.spans.size) + val httpClientSpan = it.spans.first() + assertEquals("http.graphql.query", httpClientSpan.op) + assertEquals("query LaunchDetails", httpClientSpan.description) + assertEquals("auto.graphql.apollo4", httpClientSpan.origin) + assertNotNull(httpClientSpan.data) { + assertNotNull(it["operationId"]) + assertNotNull(it["variables"]) + } + } + + private fun executeQuery(sut: ApolloClient = fixture.getSut(), isSpanActive: Boolean = true, id: String = "83") = runBlocking { + var tx: ITransaction? = null + if (isSpanActive) { + tx = SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) + } + + val coroutine = launch { + try { + executeQueryImplementation(sut.query(LaunchDetailsQuery(id))) + } catch (e: ApolloException) { + return@launch + } + } + + coroutine.join() + tx?.finish() + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt new file mode 100644 index 00000000000..f0344f62ec1 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt @@ -0,0 +1,388 @@ +package io.sentry.apollo4 + +import com.apollographql.apollo.ApolloCall +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.api.http.HttpRequest +import com.apollographql.apollo.api.http.HttpResponse +import com.apollographql.apollo.exception.ApolloException +import com.apollographql.apollo.exception.ApolloHttpException +import com.apollographql.apollo.network.http.HttpInterceptor +import com.apollographql.apollo.network.http.HttpInterceptorChain +import io.sentry.BaggageHeader +import io.sentry.Breadcrumb +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanDataConvention.HTTP_METHOD_KEY +import io.sentry.SpanStatus +import io.sentry.TraceContext +import io.sentry.TracesSamplingDecision +import io.sentry.TransactionContext +import io.sentry.apollo4.SentryApollo4HttpInterceptor.BeforeSpanCallback +import io.sentry.apollo4.generated.LaunchDetailsQuery +import io.sentry.mockServerRequestTimeoutMillis +import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryTransaction +import io.sentry.util.Apollo4PlatformTestManipulator +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.Before +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.concurrent.TimeUnit +import kotlin.reflect.KSuspendFunction1 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SentryApollo4HttpInterceptorTestWithV4Implementation : SentryApollo4HttpInterceptorTest(ApolloCall<*>::execute) +class SentryApollo4HttpInterceptorTestWithV3Implementation : SentryApollo4HttpInterceptorTest(ApolloCall<*>::executeV3) + +abstract class SentryApollo4HttpInterceptorTest( + private val executeQueryImplementation: KSuspendFunction1, ApolloResponse> +) { + + class Fixture { + val server = MockWebServer() + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + setTracePropagationTargets(listOf(DEFAULT_PROPAGATION_TARGETS)) + sdkVersion = SdkVersion("test", "1.2.3") + } + val scope = Scope(options) + val scopes = mock().also { + whenever(it.options).thenReturn(options) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(it).configureScope(any()) + } + private var httpInterceptor = SentryApollo4HttpInterceptor(scopes, captureFailedRequests = false) + + @SuppressWarnings("LongParameterList") + fun getSut( + httpStatusCode: Int = 200, + responseBody: String = """{ + "data": { + "launch": { + "__typename": "Launch", + "id": "83", + "site": "CCAFS SLC 40", + "mission": { + "__typename": "Mission", + "name": "Amos-17", + "missionPatch": "https://images2.imgbox.com/a0/ab/XUoByiuR_o.png" + } + } + } +}""", + socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + interceptor: HttpInterceptor? = null, + addThirdPartyBaggageHeader: Boolean = false, + beforeSpan: BeforeSpanCallback? = null + ): ApolloClient { + server.enqueue( + MockResponse() + .setBody(responseBody) + .setSocketPolicy(socketPolicy) + .setResponseCode(httpStatusCode) + ) + + if (beforeSpan != null) { + httpInterceptor = SentryApollo4HttpInterceptor(scopes, beforeSpan, captureFailedRequests = false) + } + + val builder = ApolloClient.Builder() + .serverUrl(server.url("/").toString()) + .addHttpInterceptor(httpInterceptor) + + interceptor?.let { + builder.addHttpInterceptor(interceptor) + } + + if (addThirdPartyBaggageHeader) { + builder.addHttpHeader("baggage", "thirdPartyBaggage=someValue") + .addHttpHeader("baggage", "secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue") + } + + return builder.build() + } + } + + private val fixture = Fixture() + + @Before + fun setup() { + Apollo4PlatformTestManipulator.pretendIsAndroid(false) + } + + @Test + fun `creates a span around the successful request`() { + executeQuery() + + verify(fixture.scopes).captureTransaction( + check { + assertTransactionDetails(it, httpStatusCode = 200) + assertEquals(SpanStatus.OK, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `creates a span around the failed request`() { + executeQuery(fixture.getSut(httpStatusCode = 403)) + + verify(fixture.scopes).captureTransaction( + check { + assertTransactionDetails(it, httpStatusCode = 403) + assertEquals(SpanStatus.PERMISSION_DENIED, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `get http status from ApolloHttpException in failed request`() { + val failingInterceptor = object : HttpInterceptor { + override suspend fun intercept(request: HttpRequest, chain: HttpInterceptorChain): HttpResponse { + throw ApolloHttpException(404, mock(), mock(), "") + } + } + executeQuery(fixture.getSut(interceptor = failingInterceptor)) + + verify(fixture.scopes).captureTransaction( + check { + assertTransactionDetails(it, httpStatusCode = 404, contentLength = null) + assertEquals("POST", it.spans.first().data?.get(SpanDataConvention.HTTP_METHOD_KEY)) + assertEquals(404, it.spans.first().data?.get(SpanDataConvention.HTTP_STATUS_CODE_KEY)) + assertEquals(SpanStatus.NOT_FOUND, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `creates a span around the request failing with network error`() { + executeQuery(fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) + + verify(fixture.scopes).captureTransaction( + check { + assertTransactionDetails(it, httpStatusCode = null, contentLength = null) + assertEquals(SpanStatus.INTERNAL_ERROR, it.spans.first().status) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `does not add sentry trace header to the request if host is disallowed`() { + fixture.options.setTracePropagationTargets(listOf("some-host-that-does-not-exist")) + executeQuery(isSpanActive = false) + + val recorderRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is no active span, does not add sentry trace header to the request`() { + executeQuery(isSpanActive = false) + + val recorderRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `does not add sentry-trace header when span origin is ignored`() { + fixture.options.setIgnoredSpanOrigins(listOf("auto.graphql.apollo4")) + executeQuery(isSpanActive = false) + + val recorderRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is an active span, adds sentry trace headers to the request`() { + executeQuery() + val recorderRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is an active span, existing baggage headers are merged with sentry baggage into single header`() { + executeQuery(sut = fixture.getSut(addThirdPartyBaggageHeader = true)) + val recorderRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + + val baggageHeaderValues = recorderRequest.headers.values(BaggageHeader.BAGGAGE_HEADER) + assertEquals(baggageHeaderValues.size, 1) + assertTrue(baggageHeaderValues[0].startsWith("thirdPartyBaggage=someValue,secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue")) + assertTrue(baggageHeaderValues[0].contains("sentry-public_key=key")) + assertTrue(baggageHeaderValues[0].contains("sentry-transaction=op")) + assertTrue(baggageHeaderValues[0].contains("sentry-trace_id")) + } + + @Test + fun `customizer modifies span`() { + executeQuery( + + fixture.getSut( + beforeSpan = { span, request, response -> + span.description = "overwritten description" + span + } + ) + ) + + verify(fixture.scopes).captureTransaction( + check { + assertEquals(1, it.spans.size) + val httpClientSpan = it.spans.first() + assertEquals("overwritten description", httpClientSpan.description) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `returning null in beforeSpan callback drops span`() { + executeQuery( + fixture.getSut( + beforeSpan = { _, _, _ -> null } + ) + ) + + verify(fixture.scopes).captureTransaction( + check { + assertEquals(0, it.spans.size) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `when customizer throws, exception is handled`() { + executeQuery( + fixture.getSut( + beforeSpan = { _, _, _ -> + throw RuntimeException() + } + ) + ) + + verify(fixture.scopes).captureTransaction( + check { + assertEquals(1, it.spans.size) + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } + + @Test + fun `adds breadcrumb when http calls succeeds`() { + executeQuery(fixture.getSut()) + verify(fixture.scopes).addBreadcrumb( + check { + assertEquals("http", it.type) + // response_body_size is added but mock webserver returns 0 always + assertEquals(0L, it.data["response_body_size"]) + assertEquals(193L, it.data["request_body_size"]) + }, + anyOrNull() + ) + } + + @Test + fun `sets SDKVersion Info`() { + assertNotNull(fixture.scopes.options.sdkVersion) + assert(fixture.scopes.options.sdkVersion!!.integrationSet.contains("Apollo4")) + val packageInfo = fixture.scopes.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-apollo-4" } + assertNotNull(packageInfo) + assert(packageInfo.version == BuildConfig.VERSION_NAME) + } + + @Test + fun `attaches to root transaction on Android`() { + Apollo4PlatformTestManipulator.pretendIsAndroid(true) + executeQuery(fixture.getSut()) + verify(fixture.scopes).transaction + } + + @Test + fun `attaches to child span on non-Android`() { + Apollo4PlatformTestManipulator.pretendIsAndroid(false) + executeQuery(fixture.getSut()) + verify(fixture.scopes).span + } + + private fun assertTransactionDetails(it: SentryTransaction, httpStatusCode: Int? = 200, contentLength: Long? = 0L) { + assertEquals(1, it.spans.size) + val httpClientSpan = it.spans.first() + assertEquals("http.graphql", httpClientSpan.op) + assertEquals("Post http://${fixture.server.hostName}:${fixture.server.port}/", httpClientSpan.description) + assertNotNull(httpClientSpan.data) { + assertEquals("POST", it[HTTP_METHOD_KEY]) + httpStatusCode?.let { code -> + assertEquals(code, it[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + } + contentLength?.let { contentLength -> + assertEquals(contentLength, it[SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY]) + } + } + } + + private fun executeQuery(sut: ApolloClient = fixture.getSut(), isSpanActive: Boolean = true, id: String = "83") = runBlocking { + var tx: ITransaction? = null + if (isSpanActive) { + tx = SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.transaction).thenReturn(tx) + whenever(fixture.scopes.span).thenReturn(tx) + } + + val coroutine = launch { + try { + executeQueryImplementation(sut.query(LaunchDetailsQuery(id))) + } catch (e: ApolloException) { + return@launch + } + } + + coroutine.join() + tx?.finish() + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/LaunchDetailsQuery.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/LaunchDetailsQuery.kt new file mode 100644 index 00000000000..5fe0b050211 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/LaunchDetailsQuery.kt @@ -0,0 +1,89 @@ +package io.sentry.apollo4.generated + +import com.apollographql.apollo.api.Adapter +import com.apollographql.apollo.api.CompiledField +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.Query +import com.apollographql.apollo.api.json.JsonWriter +import com.apollographql.apollo.api.obj +import io.sentry.apollo4.generated.adapter.LaunchDetailsQuery_ResponseAdapter +import io.sentry.apollo4.generated.adapter.LaunchDetailsQuery_VariablesAdapter +import io.sentry.apollo4.generated.selections.LaunchDetailsQuerySelections +import kotlin.String + +public data class LaunchDetailsQuery( + public val id: String +) : Query { + public override fun id(): String = OPERATION_ID + + public override fun document(): String = OPERATION_DOCUMENT + + public override fun name(): String = OPERATION_NAME + + public override fun serializeVariables( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + withDefaultValues: Boolean + ) { + LaunchDetailsQuery_VariablesAdapter.toJson(writer, customScalarAdapters, this) + } + + public override fun adapter(): Adapter = LaunchDetailsQuery_ResponseAdapter.Data.obj() + + public override fun rootField(): CompiledField = CompiledField.Builder( + name = "data", + type = io.sentry.apollo4.generated.type.Query.type + ) + .selections(selections = LaunchDetailsQuerySelections.root) + .build() + + public data class Data( + public val launch: Launch? + ) : Query.Data + + public data class Launch( + public val id: String, + public val site: String?, + public val mission: Mission?, + public val rocket: Rocket? + ) + + public data class Mission( + public val name: String?, + public val missionPatch: String? + ) + + public data class Rocket( + public val name: String?, + public val type: String? + ) + + public companion object { + public const val OPERATION_ID: String = + "1b3bda4a2dcb47a77aa30346e10339d4600e0cbe9fa686867e9226e463b7118d" + + /** + * The minimized GraphQL document being sent to the server to save a few bytes. + * The un-minimized version is: + * + * query LaunchDetails($id: ID!) { + * launch(id: $id) { + * id + * site + * mission { + * name + * missionPatch(size: LARGE) + * } + * rocket { + * name + * type + * } + * } + * } + */ + public const val OPERATION_DOCUMENT: String = + "query LaunchDetails(${'$'}id: ID!) { launch(id: ${'$'}id) { id site mission { name missionPatch(size: LARGE) } rocket { name type } } }" + + public const val OPERATION_NAME: String = "LaunchDetails" + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_ResponseAdapter.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_ResponseAdapter.kt new file mode 100644 index 00000000000..8926f7d4f17 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_ResponseAdapter.kt @@ -0,0 +1,166 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.generated.adapter + +import com.apollographql.apollo.api.Adapter +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.NullableStringAdapter +import com.apollographql.apollo.api.StringAdapter +import com.apollographql.apollo.api.json.JsonReader +import com.apollographql.apollo.api.json.JsonWriter +import com.apollographql.apollo.api.nullable +import com.apollographql.apollo.api.obj +import io.sentry.apollo4.generated.LaunchDetailsQuery +import kotlin.String +import kotlin.collections.List + +public object LaunchDetailsQuery_ResponseAdapter { + public object Data : Adapter { + public val RESPONSE_NAMES: List = listOf("launch") + + public override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): + LaunchDetailsQuery.Data { + var launch: LaunchDetailsQuery.Launch? = null + + while (true) { + when (reader.selectName(RESPONSE_NAMES)) { + 0 -> launch = Launch.obj().nullable().fromJson(reader, customScalarAdapters) + else -> break + } + } + + return LaunchDetailsQuery.Data( + launch = launch + ) + } + + public override fun toJson( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + `value`: LaunchDetailsQuery.Data + ) { + writer.name("launch") + Launch.obj().nullable().toJson(writer, customScalarAdapters, value.launch) + } + } + + public object Launch : Adapter { + public val RESPONSE_NAMES: List = listOf("id", "site", "mission", "rocket") + + public override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): + LaunchDetailsQuery.Launch { + var id: String? = null + var site: String? = null + var mission: LaunchDetailsQuery.Mission? = null + var rocket: LaunchDetailsQuery.Rocket? = null + + while (true) { + when (reader.selectName(RESPONSE_NAMES)) { + 0 -> id = StringAdapter.fromJson(reader, customScalarAdapters) + 1 -> site = NullableStringAdapter.fromJson(reader, customScalarAdapters) + 2 -> mission = Mission.obj().nullable().fromJson(reader, customScalarAdapters) + 3 -> rocket = Rocket.obj().nullable().fromJson(reader, customScalarAdapters) + else -> break + } + } + + return LaunchDetailsQuery.Launch( + id = id!!, + site = site, + mission = mission, + rocket = rocket + ) + } + + public override fun toJson( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + `value`: LaunchDetailsQuery.Launch + ) { + writer.name("id") + StringAdapter.toJson(writer, customScalarAdapters, value.id) + + writer.name("site") + NullableStringAdapter.toJson(writer, customScalarAdapters, value.site) + + writer.name("mission") + Mission.obj().nullable().toJson(writer, customScalarAdapters, value.mission) + + writer.name("rocket") + Rocket.obj().nullable().toJson(writer, customScalarAdapters, value.rocket) + } + } + + public object Mission : Adapter { + public val RESPONSE_NAMES: List = listOf("name", "missionPatch") + + public override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): + LaunchDetailsQuery.Mission { + var name: String? = null + var missionPatch: String? = null + + while (true) { + when (reader.selectName(RESPONSE_NAMES)) { + 0 -> name = NullableStringAdapter.fromJson(reader, customScalarAdapters) + 1 -> missionPatch = NullableStringAdapter.fromJson(reader, customScalarAdapters) + else -> break + } + } + + return LaunchDetailsQuery.Mission( + name = name, + missionPatch = missionPatch + ) + } + + public override fun toJson( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + `value`: LaunchDetailsQuery.Mission + ) { + writer.name("name") + NullableStringAdapter.toJson(writer, customScalarAdapters, value.name) + + writer.name("missionPatch") + NullableStringAdapter.toJson(writer, customScalarAdapters, value.missionPatch) + } + } + + public object Rocket : Adapter { + public val RESPONSE_NAMES: List = listOf("name", "type") + + public override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): + LaunchDetailsQuery.Rocket { + var name: String? = null + var type: String? = null + + while (true) { + when (reader.selectName(RESPONSE_NAMES)) { + 0 -> name = NullableStringAdapter.fromJson(reader, customScalarAdapters) + 1 -> type = NullableStringAdapter.fromJson(reader, customScalarAdapters) + else -> break + } + } + + return LaunchDetailsQuery.Rocket( + name = name, + type = type + ) + } + + public override fun toJson( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + `value`: LaunchDetailsQuery.Rocket + ) { + writer.name("name") + NullableStringAdapter.toJson(writer, customScalarAdapters, value.name) + + writer.name("type") + NullableStringAdapter.toJson(writer, customScalarAdapters, value.type) + } + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_VariablesAdapter.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_VariablesAdapter.kt new file mode 100644 index 00000000000..8e2e96c92ac --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/adapter/LaunchDetailsQuery_VariablesAdapter.kt @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.generated.adapter + +import com.apollographql.apollo.api.Adapter +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.StringAdapter +import com.apollographql.apollo.api.json.JsonReader +import com.apollographql.apollo.api.json.JsonWriter +import io.sentry.apollo4.generated.LaunchDetailsQuery + +object LaunchDetailsQuery_VariablesAdapter : Adapter { + override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): + LaunchDetailsQuery = throw IllegalStateException("Input type used in output position") + + override fun toJson( + writer: JsonWriter, + customScalarAdapters: CustomScalarAdapters, + `value`: LaunchDetailsQuery + ) { + writer.name("id") + StringAdapter.toJson(writer, customScalarAdapters, value.id) + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/selections/LaunchDetailsQuerySelections.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/selections/LaunchDetailsQuerySelections.kt new file mode 100644 index 00000000000..de8836c3304 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/selections/LaunchDetailsQuerySelections.kt @@ -0,0 +1,82 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.generated.selections + +import com.apollographql.apollo.api.CompiledArgument +import com.apollographql.apollo.api.CompiledArgumentDefinition +import com.apollographql.apollo.api.CompiledField +import com.apollographql.apollo.api.CompiledSelection +import com.apollographql.apollo.api.notNull +import io.sentry.apollo4.generated.type.GraphQLID +import io.sentry.apollo4.generated.type.GraphQLString +import io.sentry.apollo4.generated.type.Launch +import io.sentry.apollo4.generated.type.Mission +import io.sentry.apollo4.generated.type.Query.Companion.type +import io.sentry.apollo4.generated.type.Rocket +import kotlin.collections.List + +public object LaunchDetailsQuerySelections { + private val mission: List = listOf( + CompiledField.Builder( + name = "name", + type = GraphQLString.type + ).build(), + CompiledField.Builder( + name = "missionPatch", + type = GraphQLString.type + ).arguments( + listOf( + CompiledArgument.Builder(CompiledArgumentDefinition.Builder("size").build()).value("LARGE").build() + ) + ) + .build() + ) + + private val rocket: List = listOf( + CompiledField.Builder( + name = "name", + type = GraphQLString.type + ).build(), + CompiledField.Builder( + name = "type", + type = GraphQLString.type + ).build() + ) + + private val launch: List = listOf( + CompiledField.Builder( + name = "id", + type = GraphQLID.type.notNull() + ).build(), + CompiledField.Builder( + name = "site", + type = GraphQLString.type + ).build(), + CompiledField.Builder( + name = "mission", + type = Mission.type + ).selections(mission) + .build(), + CompiledField.Builder( + name = "rocket", + type = Rocket.type + ).selections(rocket) + .build() + ) + + public val root: List = listOf( + CompiledField.Builder( + name = "launch", + type = Launch.type + ).arguments( + listOf( + CompiledArgument.Builder(CompiledArgumentDefinition.Builder("id").build()).value("id").build() + ) + ) + .selections(launch) + .build() + ) +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLBoolean.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLBoolean.kt new file mode 100644 index 00000000000..939d391e3f2 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLBoolean.kt @@ -0,0 +1,17 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.generated.type + +import com.apollographql.apollo.api.CustomScalarType + +/** + * The `Boolean` scalar type represents `true` or `false`. + */ +public class GraphQLBoolean { + public companion object { + public val type: CustomScalarType = CustomScalarType("Boolean", "kotlin.Boolean") + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLID.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLID.kt new file mode 100644 index 00000000000..4aea4184a1c --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLID.kt @@ -0,0 +1,20 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.generated.type + +import com.apollographql.apollo.api.CustomScalarType + +/** + * The `ID` scalar type represents a unique identifier, often used to refetch an object or as key + * for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be + * human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) + * input value will be accepted as an ID. + */ +public class GraphQLID { + public companion object { + public val type: CustomScalarType = CustomScalarType("ID", "kotlin.String") + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLString.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLString.kt new file mode 100644 index 00000000000..96394bfe4d8 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/GraphQLString.kt @@ -0,0 +1,18 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.generated.type + +import com.apollographql.apollo.api.CustomScalarType + +/** + * The `String` scalar type represents textual data, represented as UTF-8 character sequences. The + * String type is most often used by GraphQL to represent free-form human-readable text. + */ +public class GraphQLString { + public companion object { + public val type: CustomScalarType = CustomScalarType("String", "kotlin.String") + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Launch.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Launch.kt new file mode 100644 index 00000000000..066c5a323db --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Launch.kt @@ -0,0 +1,14 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.generated.type + +import com.apollographql.apollo.api.ObjectType + +public class Launch { + public companion object { + public val type: ObjectType = ObjectType.Builder(name = "Launch").build() + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Mission.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Mission.kt new file mode 100644 index 00000000000..070fa9258fb --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Mission.kt @@ -0,0 +1,14 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.generated.type + +import com.apollographql.apollo.api.ObjectType + +public class Mission { + public companion object { + public val type: ObjectType = ObjectType.Builder(name = "Mission").build() + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Query.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Query.kt new file mode 100644 index 00000000000..ca72e331474 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Query.kt @@ -0,0 +1,14 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.generated.type + +import com.apollographql.apollo.api.ObjectType + +public class Query { + public companion object { + public val type: ObjectType = ObjectType.Builder(name = "Query").build() + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Rocket.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Rocket.kt new file mode 100644 index 00000000000..3d43df676fb --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/generated/type/Rocket.kt @@ -0,0 +1,14 @@ +// +// AUTO-GENERATED FILE. DO NOT MODIFY. +// +// This class was automatically generated by Apollo GraphQL version '3.3.0'. +// +package io.sentry.apollo4.generated.type + +import com.apollographql.apollo.api.ObjectType + +public class Rocket { + public companion object { + public val type: ObjectType = ObjectType.Builder(name = "Rocket").build() + } +} diff --git a/sentry-apollo-4/src/test/java/io/sentry/util/Apollo4PlatformTestManipulator.kt b/sentry-apollo-4/src/test/java/io/sentry/util/Apollo4PlatformTestManipulator.kt new file mode 100644 index 00000000000..f47438550e4 --- /dev/null +++ b/sentry-apollo-4/src/test/java/io/sentry/util/Apollo4PlatformTestManipulator.kt @@ -0,0 +1,8 @@ +package io.sentry.util + +object Apollo4PlatformTestManipulator { + + fun pretendIsAndroid(isAndroid: Boolean) { + Platform.isAndroid = isAndroid + } +} diff --git a/sentry-apollo-4/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/sentry-apollo-4/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..1f0955d450f --- /dev/null +++ b/sentry-apollo-4/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/settings.gradle.kts b/settings.gradle.kts index 28644604f01..e5fdc079f79 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,7 @@ include( "sentry-compose-helper", "sentry-apollo", "sentry-apollo-3", + "sentry-apollo-4", "sentry-test-support", "sentry-log4j2", "sentry-logback", From e5e95de3052c9145e155d771e2aa33090a150b56 Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Mon, 24 Feb 2025 18:27:52 +0100 Subject: [PATCH 005/914] feat: Added `enableTraceIdGeneration` option (#4188) --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 2 ++ .../core/ActivityLifecycleIntegration.java | 4 ++- .../android/core/ManifestMetadataReader.java | 10 ++++++ .../android/core/SentryAndroidOptions.java | 14 ++++++++ .../gestures/SentryGestureListener.java | 4 ++- .../core/ActivityLifecycleIntegrationTest.kt | 25 ++++++++++++++- .../SentryGestureListenerTracingTest.kt | 32 ++++++++++++++++++- 8 files changed, 88 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f81d76ab606..52dddc8b44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - The `ignoredErrors` option is now configurable via the manifest property `io.sentry.traces.ignored-errors` ([#4178](https://github.com/getsentry/sentry-java/pull/4178)) - A list of active Spring profiles is attached to payloads sent to Sentry (errors, traces, etc.) and displayed in the UI when using our Spring or Spring Boot integrations ([#4147](https://github.com/getsentry/sentry-java/pull/4147)) - This consists of an empty list when only the default profile is active +- Added `enableTraceIdGeneration` to the AndroidOptions. This allows Hybrid SDKs to "freeze" and control the trace and connect errors on different layers of the application ([4188](https://github.com/getsentry/sentry-java/pull/4188)) - Move to a single NetworkCallback listener to reduce number of IPC calls on Android ([#4164](https://github.com/getsentry/sentry-java/pull/4164)) - Add GraphQL Apollo Kotlin 4 integration ([#4166](https://github.com/getsentry/sentry-java/pull/4166)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index ff982b51f69..59caf171564 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -292,6 +292,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnableAppComponentBreadcrumbs ()Z public fun isEnableAppLifecycleBreadcrumbs ()Z public fun isEnableAutoActivityLifecycleTracing ()Z + public fun isEnableAutoTraceIdGeneration ()Z public fun isEnableFramesTracking ()Z public fun isEnableNdk ()Z public fun isEnableNetworkEventBreadcrumbs ()Z @@ -315,6 +316,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableAppComponentBreadcrumbs (Z)V public fun setEnableAppLifecycleBreadcrumbs (Z)V public fun setEnableAutoActivityLifecycleTracing (Z)V + public fun setEnableAutoTraceIdGeneration (Z)V public fun setEnableFramesTracking (Z)V public fun setEnableNdk (Z)V public fun setEnableNetworkEventBreadcrumbs (Z)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 3c0d8b3a5c7..0bdfee71fd4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -161,7 +161,9 @@ private void startTracing(final @NotNull Activity activity) { if (scopes != null && !isRunningTransactionOrTrace(activity)) { if (!performanceEnabled) { activitiesWithOngoingTransactions.put(activity, NoOpTransaction.getInstance()); - TracingUtils.startNewTrace(scopes); + if (options.isEnableAutoTraceIdGeneration()) { + TracingUtils.startNewTrace(scopes); + } } else { // as we allow a single transaction running on the bound Scope, we finish the previous ones stopPreviousTransactions(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index e2389e60492..86d9d6aa292 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -107,6 +107,9 @@ final class ManifestMetadataReader { static final String IGNORED_ERRORS = "io.sentry.ignored-errors"; + static final String ENABLE_AUTO_TRACE_ID_GENERATION = + "io.sentry.traces.enable-auto-id-generation"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -380,6 +383,13 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_SCOPE_PERSISTENCE, options.isEnableScopePersistence())); + options.setEnableAutoTraceIdGeneration( + readBool( + metadata, + logger, + ENABLE_AUTO_TRACE_ID_GENERATION, + options.isEnableAutoTraceIdGeneration())); + if (options.getSessionReplay().getSessionSampleRate() == null) { final Double sessionSampleRate = readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 9c32920be89..f9de207b7e3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -166,6 +166,12 @@ public final class SentryAndroidOptions extends SentryOptions { */ private boolean enableScopeSync = true; + /** + * Whether to enable automatic trace ID generation. This is mainly used by the Hybrid SDKs to + * control the trace ID generation from the outside. + */ + private boolean enableAutoTraceIdGeneration = true; + public interface BeforeCaptureCallback { /** @@ -594,4 +600,12 @@ public void setFrameMetricsCollector( final @Nullable SentryFrameMetricsCollector frameMetricsCollector) { this.frameMetricsCollector = frameMetricsCollector; } + + public boolean isEnableAutoTraceIdGeneration() { + return enableAutoTraceIdGeneration; + } + + public void setEnableAutoTraceIdGeneration(final boolean enableAutoTraceIdGeneration) { + this.enableAutoTraceIdGeneration = enableAutoTraceIdGeneration; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index cd80f5ced7d..ab90f82df41 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -202,7 +202,9 @@ private void startTracing(final @NotNull UiElement target, final @NotNull Gestur if (!(options.isTracingEnabled() && options.isEnableUserInteractionTracing())) { if (isNewInteraction) { - TracingUtils.startNewTrace(scopes); + if (options.isEnableAutoTraceIdGeneration()) { + TracingUtils.startNewTrace(scopes); + } activeUiElement = target; activeEventType = eventType; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index a14f62c3f03..317dbc843c2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -1385,10 +1385,11 @@ class ActivityLifecycleIntegrationTest { } @Test - fun `starts new trace if performance is disabled`() { + fun `starts new trace if performance is disabled and trace ID generation is enabled`() { val sut = fixture.getSut() val activity = mock() fixture.options.tracesSampleRate = null + fixture.options.isEnableAutoTraceIdGeneration = true val argumentCaptor: ArgumentCaptor = ArgumentCaptor.forClass(ScopeCallback::class.java) val scope = Scope(fixture.options) @@ -1405,6 +1406,28 @@ class ActivityLifecycleIntegrationTest { assertNotSame(propagationContextAtStart, scope.propagationContext) } + @Test + fun `does not start a new trace if performance is disabled and trace ID generation is disabled`() { + val sut = fixture.getSut() + val activity = mock() + fixture.options.tracesSampleRate = null + fixture.options.isEnableAutoTraceIdGeneration = false + + val argumentCaptor: ArgumentCaptor = ArgumentCaptor.forClass(ScopeCallback::class.java) + val scope = Scope(fixture.options) + val propagationContextAtStart = scope.propagationContext + whenever(fixture.scopes.configureScope(argumentCaptor.capture())).thenAnswer { + argumentCaptor.value.run(scope) + } + + sut.register(fixture.scopes, fixture.options) + sut.onActivityCreated(activity, fixture.bundle) + + // once for the screen + verify(fixture.scopes).configureScope(any()) + assertSame(propagationContextAtStart, scope.propagationContext) + } + @Test fun `sets the activity as the current screen`() { val sut = fixture.getSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt index 07dde15e8f1..c41a5151931 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt @@ -35,6 +35,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -59,12 +60,14 @@ class SentryGestureListenerTracingTest { hasViewIdInRes: Boolean = true, tracesSampleRate: Double? = 1.0, isEnableUserInteractionTracing: Boolean = true, - transaction: SentryTracer? = null + transaction: SentryTracer? = null, + isEnableAutoTraceIdGeneration: Boolean = true ): SentryGestureListener { options.tracesSampleRate = tracesSampleRate options.isEnableUserInteractionTracing = isEnableUserInteractionTracing options.isEnableUserInteractionBreadcrumbs = true options.gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) + options.isEnableAutoTraceIdGeneration = isEnableAutoTraceIdGeneration whenever(scopes.options).thenReturn(options) @@ -370,6 +373,33 @@ class SentryGestureListenerTracingTest { assertEquals(OUT_OF_RANGE, fixture.transaction.status) } + @Test + fun `when tracing is disabled and auto trace id generation is disabled, does not start a new trace`() { + val sut = fixture.getSut(tracesSampleRate = null, isEnableAutoTraceIdGeneration = false) + + sut.onSingleTapUp(fixture.event) + + verify(fixture.scopes, never()).configureScope(any()) + } + + @Test + fun `when tracing is disabled and auto trace id generation is enabled, starts a new trace`() { + val sut = fixture.getSut(tracesSampleRate = null, isEnableAutoTraceIdGeneration = true) + val scope = Scope(fixture.options) + val initialPropagationContext = scope.propagationContext + + sut.onSingleTapUp(fixture.event) + + verify(fixture.scopes).configureScope( + check { callback -> + callback.run(scope) + // Verify that a new propagation context was set and it's different from the initial one + assertNotNull(scope.propagationContext) + assertNotEquals(initialPropagationContext, scope.propagationContext) + } + ) + } + internal open class ScrollableListView : AbsListView(mock()) { override fun getAdapter(): ListAdapter = mock() override fun setSelection(position: Int) = Unit From afe9d2c76448bf1da820cc773307f27874b8c2ce Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Feb 2025 12:28:16 +0100 Subject: [PATCH 006/914] Fix `ignoredErrors`, `ignoredTransactions` and `ignoredCheckIns` being unset by external options (#4207) * When parsing ExternalOptions that are missing keep the value in SentryOptions for filter lists * changelog --- CHANGELOG.md | 3 +++ sentry/api/sentry.api | 1 + .../main/java/io/sentry/ExternalOptions.java | 6 ++--- .../io/sentry/config/PropertiesProvider.java | 12 +++++++++ .../java/io/sentry/ExternalOptionsTest.kt | 24 +++++++++++++++++ .../test/java/io/sentry/SentryOptionsTest.kt | 27 +++++++++++++++++++ 6 files changed, 70 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52dddc8b44a..3c7fcb144bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ ### Fixes - `SentryOptions.setTracePropagationTargets` is no longer marked internal ([#4170](https://github.com/getsentry/sentry-java/pull/4170)) +- Fix `ignoredErrors`, `ignoredTransactions` and `ignoredCheckIns` being unset by external options like `sentry.properties` or ENV vars ([#4207](https://github.com/getsentry/sentry-java/pull/4207)) + - Whenever parsing of external options was enabled (`enableExternalConfiguration`), which is the default for many integrations, the values set on `SentryOptions` passed to `Sentry.init` would be lost + - Even if the value was not set in any external configuration it would still be set to an empty list ### Behavioural Changes diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4b6007f4619..82e18268c99 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4029,6 +4029,7 @@ public abstract interface class io/sentry/config/PropertiesProvider { public fun getBooleanProperty (Ljava/lang/String;)Ljava/lang/Boolean; public fun getDoubleProperty (Ljava/lang/String;)Ljava/lang/Double; public fun getList (Ljava/lang/String;)Ljava/util/List; + public fun getListOrNull (Ljava/lang/String;)Ljava/util/List; public fun getLongProperty (Ljava/lang/String;)Ljava/lang/Long; public abstract fun getMap (Ljava/lang/String;)Ljava/util/Map; public abstract fun getProperty (Ljava/lang/String;)Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index ed2b4e1103a..62954a0e9b1 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -128,7 +128,7 @@ public final class ExternalOptions { } options.setIdleTimeout(propertiesProvider.getLongProperty("idle-timeout")); - options.setIgnoredErrors(propertiesProvider.getList("ignored-errors")); + options.setIgnoredErrors(propertiesProvider.getListOrNull("ignored-errors")); options.setEnabled(propertiesProvider.getBooleanProperty("enabled")); @@ -138,8 +138,8 @@ public final class ExternalOptions { options.setSendModules(propertiesProvider.getBooleanProperty("send-modules")); options.setSendDefaultPii(propertiesProvider.getBooleanProperty("send-default-pii")); - options.setIgnoredCheckIns(propertiesProvider.getList("ignored-checkins")); - options.setIgnoredTransactions(propertiesProvider.getList("ignored-transactions")); + options.setIgnoredCheckIns(propertiesProvider.getListOrNull("ignored-checkins")); + options.setIgnoredTransactions(propertiesProvider.getListOrNull("ignored-transactions")); options.setEnableBackpressureHandling( propertiesProvider.getBooleanProperty("enable-backpressure-handling")); diff --git a/sentry/src/main/java/io/sentry/config/PropertiesProvider.java b/sentry/src/main/java/io/sentry/config/PropertiesProvider.java index 5dc2e36741f..b30bb8edb7c 100644 --- a/sentry/src/main/java/io/sentry/config/PropertiesProvider.java +++ b/sentry/src/main/java/io/sentry/config/PropertiesProvider.java @@ -38,6 +38,18 @@ default List getList(final @NotNull String property) { return value != null ? Arrays.asList(value.split(",")) : Collections.emptyList(); } + /** + * Resolves a list of values for a property given by it's name. + * + * @param property - the property name + * @return the list or null if not found + */ + @Nullable + default List getListOrNull(final @NotNull String property) { + final String value = getProperty(property); + return value != null ? Arrays.asList(value.split(",")) : null; + } + /** * Resolves property given by it's name. * diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index f32b6cf8c01..5bb0e5bae0c 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -218,6 +218,14 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with null ignored errors if missing`() { + val logger = mock() + withPropertiesFile("Another .*", logger) { options -> + assertNull(options.ignoredErrors) + } + } + @Test fun `creates options with single bundle ID using external properties`() { withPropertiesFile("bundle-ids=12ea7a02-46ac-44c0-a5bb-6d1fd9586411") { options -> @@ -270,6 +278,14 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with null ignoredCheckIns if missing`() { + val logger = mock() + withPropertiesFile("Another .*", logger) { options -> + assertNull(options.ignoredCheckIns) + } + } + @Test fun `creates options with ignoredTransactions`() { withPropertiesFile("ignored-transactions=transactionName1,transactionName2") { options -> @@ -277,6 +293,14 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with null ignoredTransactions if missing`() { + val logger = mock() + withPropertiesFile("Another .*", logger) { options -> + assertNull(options.ignoredTransactions) + } + } + @Test fun `creates options with enableBackpressureHandling set to false`() { withPropertiesFile("enable-backpressure-handling=false") { options -> diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 278c3519162..e2f4692357c 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -668,4 +668,31 @@ class SentryOptionsTest { fun `when options is initialized, InitPriority is set to MEDIUM by default`() { assertEquals(SentryOptions().initPriority, InitPriority.MEDIUM) } + + @Test + fun `merging options when ignoredErrors is not set preserves the previous value`() { + val externalOptions = ExternalOptions() + val options = SentryOptions() + options.setIgnoredErrors(listOf("error1", "error2")) + options.merge(externalOptions) + assertEquals(listOf(FilterString("error1"), FilterString("error2")), options.ignoredErrors) + } + + @Test + fun `merging options when ignoredTransactions is not set preserves the previous value`() { + val externalOptions = ExternalOptions() + val options = SentryOptions() + options.setIgnoredTransactions(listOf("transaction1", "transaction2")) + options.merge(externalOptions) + assertEquals(listOf(FilterString("transaction1"), FilterString("transaction2")), options.ignoredTransactions) + } + + @Test + fun `merging options when ignoredCheckIns is not set preserves the previous value`() { + val externalOptions = ExternalOptions() + val options = SentryOptions() + options.setIgnoredCheckIns(listOf("checkin1", "checkin2")) + options.merge(externalOptions) + assertEquals(listOf(FilterString("checkin1"), FilterString("checkin2")), options.ignoredCheckIns) + } } From c8461d4fa418fdd73cf132d02f46e8b17be9f477 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Feb 2025 15:04:04 +0100 Subject: [PATCH 007/914] Check `tracePropagationTargets` in OpenTelemetry propagator (#4191) * Check tracePropagationTargets in OpenTelemetry propagator * expose Attributes instead of ReadWriteSpan * add test for propagator * changelog * remove testing files --- CHANGELOG.md | 3 + .../api/sentry-opentelemetry-bootstrap.api | 3 + .../opentelemetry/IOtelSpanWrapper.java | 5 + .../OtelStrongRefSpanWrapper.java | 7 + .../opentelemetry/SentryWeakSpanStorage.java | 6 + .../api/sentry-opentelemetry-core.api | 2 + .../OpenTelemetryAttributesExtractor.java | 30 +- .../opentelemetry/OtelSentryPropagator.java | 17 +- .../sentry/opentelemetry/OtelSpanWrapper.java | 11 + .../OpenTelemetryAttributesExtractorTest.kt | 116 +++++++ .../test/kotlin/OtelSentryPropagatorTest.kt | 320 ++++++++++++++++++ 11 files changed, 507 insertions(+), 13 deletions(-) create mode 100644 sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c7fcb144bf..b620355f2d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ ### Fixes - `SentryOptions.setTracePropagationTargets` is no longer marked internal ([#4170](https://github.com/getsentry/sentry-java/pull/4170)) +- Check `tracePropagationTargets` in OpenTelemetry propagator ([#4191](https://github.com/getsentry/sentry-java/pull/4191)) + - If a URL can be retrieved from OpenTelemetry span attributes, we check it against `tracePropagationTargets` before attaching `sentry-trace` and `baggage` headers to outgoing requests + - If no URL can be retrieved we always attach the headers - Fix `ignoredErrors`, `ignoredTransactions` and `ignoredCheckIns` being unset by external options like `sentry.properties` or ENV vars ([#4207](https://github.com/getsentry/sentry-java/pull/4207)) - Whenever parsing of external options was enabled (`enableExternalConfiguration`), which is the default for many integrations, the values set on `SentryOptions` passed to `Sentry.init` would be lost - Even if the value was not set in any external configuration it would still be set to an empty list diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api index adb976adc05..8eeb9936f7d 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api @@ -1,6 +1,7 @@ public abstract interface class io/sentry/opentelemetry/IOtelSpanWrapper : io/sentry/ISpan { public abstract fun getData ()Ljava/util/Map; public abstract fun getMeasurements ()Ljava/util/Map; + public abstract fun getOpenTelemetrySpanAttributes ()Lio/opentelemetry/api/common/Attributes; public abstract fun getScopes ()Lio/sentry/IScopes; public abstract fun getTags ()Ljava/util/Map; public abstract fun getTraceId ()Lio/sentry/protocol/SentryId; @@ -51,6 +52,7 @@ public final class io/sentry/opentelemetry/OtelStrongRefSpanWrapper : io/sentry/ public fun getDescription ()Ljava/lang/String; public fun getFinishDate ()Lio/sentry/SentryDate; public fun getMeasurements ()Ljava/util/Map; + public fun getOpenTelemetrySpanAttributes ()Lio/opentelemetry/api/common/Attributes; public fun getOperation ()Ljava/lang/String; public fun getSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getScopes ()Lio/sentry/IScopes; @@ -177,6 +179,7 @@ public final class io/sentry/opentelemetry/SentryOtelThreadLocalStorage : io/ope } public final class io/sentry/opentelemetry/SentryWeakSpanStorage { + public fun clear ()V public static fun getInstance ()Lio/sentry/opentelemetry/SentryWeakSpanStorage; public fun getSentrySpan (Lio/opentelemetry/api/trace/SpanContext;)Lio/sentry/opentelemetry/IOtelSpanWrapper; public fun storeSentrySpan (Lio/opentelemetry/api/trace/SpanContext;Lio/sentry/opentelemetry/IOtelSpanWrapper;)V diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/IOtelSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/IOtelSpanWrapper.java index 0184db0eabe..1eefc854a8a 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/IOtelSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/IOtelSpanWrapper.java @@ -1,5 +1,6 @@ package io.sentry.opentelemetry; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.context.Context; import io.sentry.IScopes; import io.sentry.ISpan; @@ -47,4 +48,8 @@ public interface IOtelSpanWrapper extends ISpan { @NotNull Context storeInContext(Context context); + + @ApiStatus.Internal + @Nullable + Attributes getOpenTelemetrySpanAttributes(); } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java index f2ea37b3350..7f026742e9f 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java @@ -1,5 +1,6 @@ package io.sentry.opentelemetry; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.Span; import io.opentelemetry.context.Context; import io.sentry.BaggageHeader; @@ -303,4 +304,10 @@ public void setContext(@NotNull String key, @NotNull Object context) { public @NotNull ISentryLifecycleToken makeCurrent() { return delegate.makeCurrent(); } + + @ApiStatus.Internal + @Override + public @Nullable Attributes getOpenTelemetrySpanAttributes() { + return delegate.getOpenTelemetrySpanAttributes(); + } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java index c28d4ed7ffb..5096c011e2a 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java @@ -7,6 +7,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; /** * Weakly references wrappers for OpenTelemetry spans meaning they'll be cleaned up when the @@ -44,4 +45,9 @@ public void storeSentrySpan( final @NotNull SpanContext otelSpan, final @NotNull IOtelSpanWrapper sentrySpan) { this.sentrySpans.put(otelSpan, sentrySpan); } + + @TestOnly + public void clear() { + sentrySpans.clear(); + } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api index 1e9bb60416b..739fc7eb1a3 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -1,6 +1,7 @@ public final class io/sentry/opentelemetry/OpenTelemetryAttributesExtractor { public fun ()V public fun extract (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/ISpan;Lio/sentry/IScope;)V + public fun extractUrl (Lio/opentelemetry/api/common/Attributes;)Ljava/lang/String; } public final class io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor : io/sentry/EventProcessor { @@ -60,6 +61,7 @@ public final class io/sentry/opentelemetry/OtelSpanWrapper : io/sentry/opentelem public fun getDescription ()Ljava/lang/String; public fun getFinishDate ()Lio/sentry/SentryDate; public fun getMeasurements ()Ljava/util/Map; + public fun getOpenTelemetrySpanAttributes ()Lio/opentelemetry/api/common/Attributes; public fun getOperation ()Ljava/lang/String; public fun getSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getScopes ()Lio/sentry/IScopes; 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 431b4d274e3..65757765184 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 @@ -24,7 +24,8 @@ public void extract( addRequestAttributesToScope(attributes, scope); } - private void addRequestAttributesToScope(Attributes attributes, IScope scope) { + private void addRequestAttributesToScope( + final @NotNull Attributes attributes, final @NotNull IScope scope) { if (scope.getRequest() == null) { scope.setRequest(new Request()); } @@ -36,20 +37,13 @@ private void addRequestAttributesToScope(Attributes attributes, IScope scope) { } if (request.getUrl() == null) { - final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); - if (urlFull != null) { - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(urlFull); + final @Nullable String url = extractUrl(attributes); + if (url != null) { + final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(url); urlDetails.applyToRequest(request); } } - if (request.getUrl() == null) { - final String urlString = buildUrlString(attributes); - if (!urlString.isEmpty()) { - request.setUrl(urlString); - } - } - if (request.getQueryString() == null) { final @Nullable String query = attributes.get(UrlAttributes.URL_QUERY); if (query != null) { @@ -59,6 +53,20 @@ private void addRequestAttributesToScope(Attributes attributes, IScope scope) { } } + public @Nullable String extractUrl(final @NotNull Attributes attributes) { + final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); + if (urlFull != null) { + return urlFull; + } + + final String urlString = buildUrlString(attributes); + if (!urlString.isEmpty()) { + return urlString; + } + + return null; + } + private @NotNull String buildUrlString(final @NotNull Attributes attributes) { final @Nullable String scheme = attributes.get(UrlAttributes.URL_SCHEME); final @Nullable String serverAddress = attributes.get(ServerAttributes.SERVER_ADDRESS); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java index fc2e3d426b7..c36f1829926 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java @@ -2,6 +2,7 @@ import static io.sentry.opentelemetry.SentryOtelKeys.SENTRY_SCOPES_KEY; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.TraceFlags; @@ -32,6 +33,8 @@ public final class OtelSentryPropagator implements TextMapPropagator { Arrays.asList(SentryTraceHeader.SENTRY_TRACE_HEADER, BaggageHeader.BAGGAGE_HEADER); private final @NotNull SentryWeakSpanStorage spanStorage = SentryWeakSpanStorage.getInstance(); private final @NotNull IScopes scopes; + private final @NotNull OpenTelemetryAttributesExtractor attributesExtractor = + new OpenTelemetryAttributesExtractor(); public OtelSentryPropagator() { this(ScopesAdapter.getInstance()); @@ -73,9 +76,11 @@ public void inject(final Context context, final C carrier, final TextMapSett return; } - // TODO can we use traceIfAllowed? do we have the URL here? need to access span attrs + final @Nullable String url = getUrl(sentrySpan); final @Nullable TracingUtils.TracingHeaders tracingHeaders = - TracingUtils.trace(scopes, Collections.emptyList(), sentrySpan); + url == null + ? TracingUtils.trace(scopes, Collections.emptyList(), sentrySpan) + : TracingUtils.traceIfAllowed(scopes, url, Collections.emptyList(), sentrySpan); if (tracingHeaders != null) { final @NotNull SentryTraceHeader sentryTraceHeader = tracingHeaders.getSentryTraceHeader(); @@ -87,6 +92,14 @@ public void inject(final Context context, final C carrier, final TextMapSett } } + private @Nullable String getUrl(final @NotNull IOtelSpanWrapper sentrySpan) { + final @Nullable Attributes attributes = sentrySpan.getOpenTelemetrySpanAttributes(); + if (attributes == null) { + return null; + } + return attributesExtractor.extractUrl(attributes); + } + @Override public Context extract( final Context context, final C carrier, final TextMapGetter getter) { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java index 34f2d2a4d7c..8d11cb8b772 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java @@ -1,5 +1,6 @@ package io.sentry.opentelemetry; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.Span; import io.opentelemetry.context.Context; import io.opentelemetry.context.Scope; @@ -198,6 +199,16 @@ public OtelSpanWrapper( return span.get(); } + @ApiStatus.Internal + @Override + public @Nullable Attributes getOpenTelemetrySpanAttributes() { + final @Nullable ReadWriteSpan readWriteSpan = span.get(); + if (readWriteSpan != null) { + return readWriteSpan.getAttributes(); + } + return null; + } + @Override public @Nullable TraceContext traceContext() { if (scopes.getOptions().isTraceSampling()) { 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 f962cfa594d..80631406713 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -173,6 +173,118 @@ class OpenTelemetryAttributesExtractorTest { thenUrlIsNotSet() } + @Test + fun `returns null if no URL in attributes`() { + givenAttributes(mapOf()) + + val url = whenExtractingUrl() + + assertNull(url) + } + + @Test + fun `returns full URL if present`() { + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "https://sentry.io/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io/some/path", url) + } + + @Test + fun `returns reconstructed URL if attributes present`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io", + ServerAttributes.SERVER_PORT to 8082L, + UrlAttributes.URL_PATH to "/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io:8082/some/path", url) + } + + @Test + fun `returns reconstructed URL if attributes present without port`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io", + UrlAttributes.URL_PATH to "/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io/some/path", url) + } + + @Test + fun `returns null URL if scheme missing`() { + givenAttributes( + mapOf( + ServerAttributes.SERVER_ADDRESS to "sentry.io", + ServerAttributes.SERVER_PORT to 8082L, + UrlAttributes.URL_PATH to "/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertNull(url) + } + + @Test + fun `returns null URL if server address missing`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_PORT to 8082L, + UrlAttributes.URL_PATH to "/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertNull(url) + } + + @Test + fun `returns reconstructed URL if attributes present without port and path`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io" + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io", url) + } + + @Test + fun `returns reconstructed URL if attributes present without path`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io", + ServerAttributes.SERVER_PORT to 8082L + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io:8082", url) + } + private fun givenAttributes(map: Map, Any>) { map.forEach { k, v -> fixture.attributes.put(k, v) @@ -183,6 +295,10 @@ class OpenTelemetryAttributesExtractorTest { OpenTelemetryAttributesExtractor().extract(fixture.spanData, fixture.sentrySpan, fixture.scope) } + private fun whenExtractingUrl(): String? { + return OpenTelemetryAttributesExtractor().extractUrl(fixture.attributes) + } + private fun thenRequestIsSet() { assertNotNull(fixture.scope.request) } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt new file mode 100644 index 00000000000..21ff416bc3f --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt @@ -0,0 +1,320 @@ +package io.sentry.opentelemetry + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.context.Context +import io.opentelemetry.context.propagation.TextMapGetter +import io.opentelemetry.context.propagation.TextMapSetter +import io.opentelemetry.semconv.UrlAttributes +import io.sentry.BaggageHeader +import io.sentry.Sentry +import io.sentry.SentryTraceHeader +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_BAGGAGE_KEY +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_SCOPES_KEY +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_TRACE_KEY +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class OtelSentryPropagatorTest { + + val spanStorage: SentryWeakSpanStorage = SentryWeakSpanStorage.getInstance() + + @BeforeTest + fun setup() { + Sentry.init("https://key@sentry.io/proj") + } + + @AfterTest + fun cleanup() { + spanStorage.clear() + } + + @Test + fun `propagator registers for sentry-trace and baggage`() { + val propagator = OtelSentryPropagator() + assertEquals(listOf("sentry-trace", "baggage"), propagator.fields()) + } + + @Test + fun `forks root scopes if none in context without headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf() + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(Sentry.forkedRootScopes("test").parentScopes, scopes.parentScopes) + } + + @Test + fun `forks scopes from context if present without headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf() + val scopeInContext = Sentry.forkedRootScopes("test") + + val newContext = propagator.extract(Context.root().with(SENTRY_SCOPES_KEY, scopeInContext), carrier, MapGetter()) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(scopeInContext, scopes.parentScopes) + } + + @Test + fun `forks root scopes if none in context with headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(Sentry.forkedRootScopes("test").parentScopes, scopes.parentScopes) + } + + @Test + fun `forks scopes from context if present with headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + val scopeInContext = Sentry.forkedRootScopes("test") + + val newContext = propagator.extract(Context.root().with(SENTRY_SCOPES_KEY, scopeInContext), carrier, MapGetter()) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(scopeInContext, scopes.parentScopes) + } + + @Test + fun `invalid sentry trace header returns context without modification`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf( + "sentry-trace" to "wrong", + "baggage" to "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + val scopeInContext = Sentry.forkedRootScopes("test") + + val newContext = propagator.extract(Context.root().with(SENTRY_SCOPES_KEY, scopeInContext), carrier, MapGetter()) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(scopeInContext, scopes) + } + + @Test + fun `uses incoming headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val span = Span.fromContext(newContext) + assertEquals("f9118105af4a2d42b4124532cd1065ff", span.spanContext.traceId) + assertEquals("424cffc8f94feeee", span.spanContext.spanId) + assertTrue(span.spanContext.isSampled) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", newContext.get(SENTRY_TRACE_KEY)?.value) + assertEquals("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", newContext.get(SENTRY_BAGGAGE_KEY)?.toHeaderString(null)) + } + + @Test + fun `injects headers if no URL`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()).thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())).thenReturn(BaggageHeader("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d")) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertEquals("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", carrier["baggage"]) + } + + @Test + fun `injects headers if URL in span attributes with default options`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()).thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())).thenReturn(BaggageHeader("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d")) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertEquals("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", carrier["baggage"]) + } + + @Test + fun `injects headers if URL in span attributes with tracePropagationTargets set to same url`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + } + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()).thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())).thenReturn(BaggageHeader("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d")) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertEquals("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", carrier["baggage"]) + } + + @Test + fun `does not inject headers if URL in span attributes with tracePropagationTargets set to different url`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("github.com")) + } + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()).thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())).thenReturn(BaggageHeader("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d")) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if URL in span attributes with tracePropagationTargets set to same url but trace sampling disabled`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + options.isTraceSampling = false + } + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()).thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())).thenReturn(BaggageHeader("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d")) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if sentry span missing`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if sentry span noop`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val sentrySpan = mock() + whenever(sentrySpan.isNoOp).thenReturn(true) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if span is missing`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + propagator.inject(Context.root(), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if span is invalid`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + propagator.inject(Context.root().with(Span.getInvalid()), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } +} + +class MapGetter() : TextMapGetter> { + + override fun keys(carrier: Map): MutableIterable { + return carrier.keys.toMutableList() + } + + override fun get(carrier: Map?, key: String): String? { + return carrier?.get(key) + } +} + +class MapSetter() : TextMapSetter> { + override fun set(carrier: MutableMap?, key: String, value: String) { + carrier?.set(key, value) + } +} From 7d08e30c94196830f2aeae3726827be384ce93b3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 25 Feb 2025 15:25:27 +0100 Subject: [PATCH 008/914] fix(session-replay): Do not crash if navigation breadcrumb has no destination (#4185) * Do not crash if navigation breadcrumb has not destination * Changelog --- CHANGELOG.md | 2 ++ .../android/replay/capture/CaptureStrategy.kt | 10 ++++++-- .../capture/SessionCaptureStrategyTest.kt | 24 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b620355f2d3..840ecdbbbbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ ### Fixes - `SentryOptions.setTracePropagationTargets` is no longer marked internal ([#4170](https://github.com/getsentry/sentry-java/pull/4170)) +- Session Replay: Fix crash when a navigation breadcrumb does not have "to" destination ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) +- Session Replay: Cap video segment duration to maximum 5 minutes to prevent endless video encoding in background ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) - Check `tracePropagationTargets` in OpenTelemetry propagator ([#4191](https://github.com/getsentry/sentry-java/pull/4191)) - If a URL can be retrieved from OpenTelemetry span attributes, we check it against `tracePropagationTargets` before attaching `sentry-trace` and `baggage` headers to outgoing requests - If no URL can be retrieved we always attach the headers diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 98007c45538..93cb5200f6e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -58,6 +58,10 @@ internal interface CaptureStrategy { companion object { private const val BREADCRUMB_START_OFFSET = 100L + // 5 minutes, otherwise relay will just drop it. Can prevent the case where the device + // time is wrong and the segment is too long. + private const val MAX_SEGMENT_DURATION = 1000L * 60 * 5 + fun createSegment( scopes: IScopes?, options: SentryOptions, @@ -76,7 +80,7 @@ internal interface CaptureStrategy { events: Deque ): ReplaySegment { val generatedVideo = cache?.createVideoOf( - duration, + minOf(duration, MAX_SEGMENT_DURATION), currentSegmentTimestamp.time, segmentId, height, @@ -179,7 +183,9 @@ internal interface CaptureStrategy { recordingPayload += rrwebEvent // fill in the urls array from navigation breadcrumbs - if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation" && + rrwebEvent.data?.getOrElse("to", { null }) is String + ) { urls.add(rrwebEvent.data!!["to"] as String) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 79afdb8f853..b7043501252 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -336,6 +336,30 @@ class SessionCaptureStrategyTest { ) } + @Test + fun `does not throw when navigation destination is not a String`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + fixture.scope.addBreadcrumb(Breadcrumb().apply { category = "navigation" }) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes).captureReplay( + check { + assertNull(it.urls?.firstOrNull()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertNull(breadcrumbEvents?.first()?.data?.get("to")) + } + ) + } + @Test fun `sets screen from scope as replay url`() { fixture.scope.screen = "MainActivity" From cde02adbc5101ac0bb140b71c93e77deda5bf93c Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 26 Feb 2025 11:35:48 +0100 Subject: [PATCH 009/914] Add constructor to JUL `SentryHandler` for disabling external config (#4208) * Add ctor to JUL for disabling external config * changelog --- CHANGELOG.md | 1 + sentry-jul/api/sentry-jul.api | 1 + .../java/io/sentry/jul/SentryHandler.java | 23 +++++++++++++++---- .../kotlin/io/sentry/jul/SentryHandlerTest.kt | 2 +- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 840ecdbbbbf..52517b2d66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Added `enableTraceIdGeneration` to the AndroidOptions. This allows Hybrid SDKs to "freeze" and control the trace and connect errors on different layers of the application ([4188](https://github.com/getsentry/sentry-java/pull/4188)) - Move to a single NetworkCallback listener to reduce number of IPC calls on Android ([#4164](https://github.com/getsentry/sentry-java/pull/4164)) - Add GraphQL Apollo Kotlin 4 integration ([#4166](https://github.com/getsentry/sentry-java/pull/4166)) +- Add constructor to JUL `SentryHandler` for disabling external config ([#4208](https://github.com/getsentry/sentry-java/pull/4208)) ### Fixes diff --git a/sentry-jul/api/sentry-jul.api b/sentry-jul/api/sentry-jul.api index 91e126ece78..f07908e3335 100644 --- a/sentry-jul/api/sentry-jul.api +++ b/sentry-jul/api/sentry-jul.api @@ -8,6 +8,7 @@ public class io/sentry/jul/SentryHandler : java/util/logging/Handler { public static final field THREAD_ID Ljava/lang/String; public fun ()V public fun (Lio/sentry/SentryOptions;)V + public fun (Lio/sentry/SentryOptions;Z)V public fun close ()V public fun flush ()V public fun getMinimumBreadcrumbLevel ()Ljava/util/logging/Level; diff --git a/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java b/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java index d6afd514e63..5d6bb6d9223 100644 --- a/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java +++ b/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java @@ -51,7 +51,7 @@ public class SentryHandler extends Handler { /** Creates an instance of SentryHandler. */ public SentryHandler() { - this(new SentryOptions(), true); + this(new SentryOptions()); } /** @@ -60,17 +60,32 @@ public SentryHandler() { * @param options the SentryOptions */ public SentryHandler(final @NotNull SentryOptions options) { - this(options, true); + this(options, true, true); + } + + /** + * Creates an instance of SentryHandler. + * + * @param options the SentryOptions + * @param enableExternalConfiguration whether external options like sentry.properties and ENV vars + * should be parsed + */ + public SentryHandler( + final @NotNull SentryOptions options, final boolean enableExternalConfiguration) { + this(options, true, enableExternalConfiguration); } /** Creates an instance of SentryHandler. */ @TestOnly - SentryHandler(final @NotNull SentryOptions options, final boolean configureFromLogManager) { + SentryHandler( + final @NotNull SentryOptions options, + final boolean configureFromLogManager, + final boolean enableExternalConfiguration) { setFilter(new DropSentryFilter()); if (configureFromLogManager) { retrieveProperties(); } - options.setEnableExternalConfiguration(true); + options.setEnableExternalConfiguration(enableExternalConfiguration); options.setInitPriority(InitPriority.LOWEST); options.setSdkVersion(createSdkVersion(options)); Sentry.init(options); diff --git a/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt index 5b7048884d3..371c6c2f502 100644 --- a/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt +++ b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt @@ -36,7 +36,7 @@ class SentryHandlerTest { options.setTransportFactory { _, _ -> transport } contextTags?.forEach { options.addContextTag(it) } logger = Logger.getLogger("jul.SentryHandlerTest") - handler = SentryHandler(options, configureWithLogManager) + handler = SentryHandler(options, configureWithLogManager, true) handler.setMinimumBreadcrumbLevel(minimumBreadcrumbLevel) handler.setMinimumEventLevel(minimumEventLevel) handler.level = Level.ALL From 3b6cfdf3d2ea2b3eb20e5b8842cb4f4904bb8913 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 26 Feb 2025 12:03:24 +0100 Subject: [PATCH 010/914] Add HTTP server request headers from OpenTelemetry span attributes to sentry `request` in payload (#4102) * Attach request object to event for OTel * fix test name * add http server request headers to sentry request in payload * rename test class * changelog * do not override existing url on request even with full url * pass in options and use them * remove span param; remove test exception * changelog * changelog pii * Use `java.net.URL` for combining url attributes (#4105) * changelog * do not send request headers in contexts/otel/attributes * also remove response headers from span attributes sent to Sentry * Apply suggestions from code review --- CHANGELOG.md | 4 + .../api/sentry-opentelemetry-core.api | 4 +- .../OpenTelemetryAttributesExtractor.java | 102 +++++++++++++----- .../opentelemetry/OtelSentryPropagator.java | 8 +- .../opentelemetry/SentrySpanExporter.java | 18 +++- .../OpenTelemetryAttributesExtractorTest.kt | 70 +++++++++++- 6 files changed, 171 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52517b2d66e..68eb9a2ec21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Features +- Add HTTP server request headers from OpenTelemetry span attributes to sentry `request` in payload ([#4102](https://github.com/getsentry/sentry-java/pull/4102)) + - You have to explicitly enable each header by adding it to the [OpenTelemetry config](https://opentelemetry.io/docs/zero-code/java/agent/instrumentation/http/#capturing-http-request-and-response-headers) + - Please only enable headers you actually want to send to Sentry. Some may contain sensitive data like PII, cookies, tokens etc. + - We are no longer adding request/response headers to `contexts/otel/attributes` of the event. - The `ignoredErrors` option is now configurable via the manifest property `io.sentry.traces.ignored-errors` ([#4178](https://github.com/getsentry/sentry-java/pull/4178)) - A list of active Spring profiles is attached to payloads sent to Sentry (errors, traces, etc.) and displayed in the UI when using our Spring or Spring Boot integrations ([#4147](https://github.com/getsentry/sentry-java/pull/4147)) - This consists of an empty list when only the default profile is active diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api index 739fc7eb1a3..f20ea0ab864 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -1,7 +1,7 @@ public final class io/sentry/opentelemetry/OpenTelemetryAttributesExtractor { public fun ()V - public fun extract (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/ISpan;Lio/sentry/IScope;)V - public fun extractUrl (Lio/opentelemetry/api/common/Attributes;)Ljava/lang/String; + public fun extract (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/IScope;Lio/sentry/SentryOptions;)V + public fun extractUrl (Lio/opentelemetry/api/common/Attributes;Lio/sentry/SentryOptions;)Ljava/lang/String; } public final class io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor : io/sentry/EventProcessor { 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 65757765184..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 @@ -6,9 +6,16 @@ import io.opentelemetry.semconv.ServerAttributes; import io.opentelemetry.semconv.UrlAttributes; import io.sentry.IScope; -import io.sentry.ISpan; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.protocol.Request; +import io.sentry.util.HttpUtils; +import io.sentry.util.StringUtils; import io.sentry.util.UrlUtils; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -16,16 +23,22 @@ @ApiStatus.Internal public final class OpenTelemetryAttributesExtractor { + private static final String HTTP_REQUEST_HEADER_PREFIX = "http.request.header."; + public void extract( final @NotNull SpanData otelSpan, - final @NotNull ISpan sentrySpan, - final @NotNull IScope scope) { + final @NotNull IScope scope, + final @NotNull SentryOptions options) { final @NotNull Attributes attributes = otelSpan.getAttributes(); - addRequestAttributesToScope(attributes, scope); + if (attributes.get(HttpAttributes.HTTP_REQUEST_METHOD) != null) { + addRequestAttributesToScope(attributes, scope, options); + } } private void addRequestAttributesToScope( - final @NotNull Attributes attributes, final @NotNull IScope scope) { + final @NotNull Attributes attributes, + final @NotNull IScope scope, + final @NotNull SentryOptions options) { if (scope.getRequest() == null) { scope.setRequest(new Request()); } @@ -37,7 +50,7 @@ private void addRequestAttributesToScope( } if (request.getUrl() == null) { - final @Nullable String url = extractUrl(attributes); + final @Nullable String url = extractUrl(attributes, options); if (url != null) { final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(url); urlDetails.applyToRequest(request); @@ -50,16 +63,56 @@ private void addRequestAttributesToScope( request.setQueryString(query); } } + + if (request.getHeaders() == null) { + Map headers = collectHeaders(attributes, options); + if (!headers.isEmpty()) { + request.setHeaders(headers); + } + } } } - public @Nullable String extractUrl(final @NotNull Attributes attributes) { + @SuppressWarnings("unchecked") + private static Map collectHeaders( + final @NotNull Attributes attributes, final @NotNull SentryOptions options) { + Map headers = new HashMap<>(); + + attributes.forEach( + (key, value) -> { + final @NotNull String attributeKeyAsString = key.getKey(); + 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 (value instanceof List) { + try { + final @NotNull List headerValues = (List) value; + headers.put( + headerName, + toString( + HttpUtils.filterOutSecurityCookiesFromHeader( + headerValues, headerName, null))); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.WARNING, "Expected a List as header", t); + } + } + } + } + }); + return headers; + } + + public @Nullable String extractUrl( + final @NotNull Attributes attributes, final @NotNull SentryOptions options) { final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); if (urlFull != null) { return urlFull; } - final String urlString = buildUrlString(attributes); + final String urlString = buildUrlString(attributes, options); if (!urlString.isEmpty()) { return urlString; } @@ -67,7 +120,12 @@ private void addRequestAttributesToScope( return null; } - private @NotNull String buildUrlString(final @NotNull Attributes attributes) { + private static @Nullable String toString(final @Nullable List list) { + return list != null ? String.join(",", list) : null; + } + + private @NotNull String buildUrlString( + final @NotNull Attributes attributes, final @NotNull SentryOptions options) { final @Nullable String scheme = attributes.get(UrlAttributes.URL_SCHEME); final @Nullable String serverAddress = attributes.get(ServerAttributes.SERVER_ADDRESS); final @Nullable Long serverPort = attributes.get(ServerAttributes.SERVER_PORT); @@ -77,22 +135,18 @@ private void addRequestAttributesToScope( return ""; } - final @NotNull StringBuilder urlBuilder = new StringBuilder(); - urlBuilder.append(scheme); - urlBuilder.append("://"); - - if (serverAddress != null) { - urlBuilder.append(serverAddress); - if (serverPort != null) { - urlBuilder.append(":"); - urlBuilder.append(serverPort); + try { + final @NotNull String pathToUse = path == null ? "" : path; + if (serverPort == null) { + return new URL(scheme, serverAddress, pathToUse).toString(); + } else { + return new URL(scheme, serverAddress, serverPort.intValue(), pathToUse).toString(); } + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.WARNING, "Unable to combine URL span attributes into one.", t); + return ""; } - - if (path != null) { - urlBuilder.append(path); - } - - return urlBuilder.toString(); } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java index c36f1829926..5e59358b09c 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java @@ -17,6 +17,7 @@ import io.sentry.ScopesAdapter; import io.sentry.Sentry; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.SentryTraceHeader; import io.sentry.exception.InvalidSentryTraceHeaderException; import io.sentry.util.TracingUtils; @@ -76,7 +77,7 @@ public void inject(final Context context, final C carrier, final TextMapSett return; } - final @Nullable String url = getUrl(sentrySpan); + final @Nullable String url = getUrl(sentrySpan, scopes.getOptions()); final @Nullable TracingUtils.TracingHeaders tracingHeaders = url == null ? TracingUtils.trace(scopes, Collections.emptyList(), sentrySpan) @@ -92,12 +93,13 @@ public void inject(final Context context, final C carrier, final TextMapSett } } - private @Nullable String getUrl(final @NotNull IOtelSpanWrapper sentrySpan) { + private @Nullable String getUrl( + final @NotNull IOtelSpanWrapper sentrySpan, final @NotNull SentryOptions options) { final @Nullable Attributes attributes = sentrySpan.getOpenTelemetrySpanAttributes(); if (attributes == null) { return null; } - return attributesExtractor.extractUrl(attributes); + return attributesExtractor.extractUrl(attributes, options); } @Override diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java index 693b94fe38d..6db21ff7979 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java @@ -68,6 +68,9 @@ public final class SentrySpanExporter implements SpanExporter { InternalSemanticAttributes.PARENT_SAMPLED.getKey(), ProcessIncubatingAttributes.PROCESS_COMMAND_ARGS.getKey() // can be very long ); + + private final @NotNull List attributeToRemoveByPrefix = + Arrays.asList("http.request.header.", "http.response.header."); private static final @NotNull Long SPAN_TIMEOUT = DateUtils.secondsToNanos(5 * 60); public static final String TRACE_ORIGIN = "auto.opentelemetry"; @@ -338,7 +341,8 @@ private void transferSpanDetails( transferSpanDetails(sentrySpanMaybe, sentryTransaction); scopesToUse.configureScope( - ScopeType.CURRENT, scope -> attributesExtractor.extract(span, sentryTransaction, scope)); + ScopeType.CURRENT, + scope -> attributesExtractor.extract(span, scope, scopesToUse.getOptions())); return sentryTransaction; } @@ -488,7 +492,17 @@ private SpanStatus mapOtelStatus( } private boolean shouldRemoveAttribute(final @NotNull String key) { - return attributeKeysToRemove.contains(key); + if (attributeKeysToRemove.contains(key)) { + return true; + } + + for (String prefix : attributeToRemoveByPrefix) { + if (key.startsWith(prefix)) { + return true; + } + } + + return false; } private void setOtelInstrumentationInfo( 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 80631406713..1227509e0d0 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -3,9 +3,9 @@ package io.sentry.opentelemetry import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.sdk.internal.AttributesMap 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.ISpan import io.sentry.Scope import io.sentry.SentryOptions import io.sentry.protocol.Request @@ -13,6 +13,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -21,7 +22,6 @@ class OpenTelemetryAttributesExtractorTest { private class Fixture { val spanData = mock() val attributes = AttributesMap.create(100, 100) - val sentrySpan = mock() val options = SentryOptions.empty() val scope = Scope(options) @@ -36,6 +36,7 @@ class OpenTelemetryAttributesExtractorTest { fun `sets URL based on OTel attributes`() { givenAttributes( mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", UrlAttributes.URL_SCHEME to "https", UrlAttributes.URL_PATH to "/path/to/123", UrlAttributes.URL_QUERY to "q=123456&b=X", @@ -56,6 +57,7 @@ class OpenTelemetryAttributesExtractorTest { fixture.scope.request = Request().also { it.bodySize = 123L } givenAttributes( mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", UrlAttributes.URL_SCHEME to "https", UrlAttributes.URL_PATH to "/path/to/123", UrlAttributes.URL_QUERY to "q=123456&b=X", @@ -80,6 +82,7 @@ class OpenTelemetryAttributesExtractorTest { } givenAttributes( mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", UrlAttributes.URL_SCHEME to "https", UrlAttributes.URL_PATH to "/path/to/123", UrlAttributes.URL_QUERY to "q=123456&b=X", @@ -118,6 +121,7 @@ class OpenTelemetryAttributesExtractorTest { fun `sets URL based on OTel attributes without port`() { givenAttributes( mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", UrlAttributes.URL_SCHEME to "https", UrlAttributes.URL_PATH to "/path/to/123", ServerAttributes.SERVER_ADDRESS to "io.sentry" @@ -134,6 +138,7 @@ class OpenTelemetryAttributesExtractorTest { fun `sets URL based on OTel attributes without path`() { givenAttributes( mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", UrlAttributes.URL_SCHEME to "https", ServerAttributes.SERVER_ADDRESS to "io.sentry" ) @@ -149,6 +154,7 @@ class OpenTelemetryAttributesExtractorTest { fun `does not set URL if server address is missing`() { givenAttributes( mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", UrlAttributes.URL_SCHEME to "https" ) ) @@ -163,6 +169,7 @@ class OpenTelemetryAttributesExtractorTest { fun `does not set URL if scheme is missing`() { givenAttributes( mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", ServerAttributes.SERVER_ADDRESS to "io.sentry" ) ) @@ -285,6 +292,49 @@ class OpenTelemetryAttributesExtractorTest { assertEquals("https://sentry.io:8082", url) } + @Test + fun `sets server request headers based on OTel attributes and merges list of values`() { + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + AttributeKey.stringArrayKey("http.request.header.baggage") to listOf("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", "another-baggage=abc,more=def"), + AttributeKey.stringArrayKey("http.request.header.sentry-trace") to listOf("f9118105af4a2d42b4124532cd176588-4542d085bb0b4de5"), + AttributeKey.stringArrayKey("http.response.header.some-header") to listOf("some-value") + ) + ) + + whenExtractingAttributes() + + thenRequestIsSet() + thenHeaderIsPresentOnRequest("baggage", "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d,another-baggage=abc,more=def") + thenHeaderIsPresentOnRequest("sentry-trace", "f9118105af4a2d42b4124532cd176588-4542d085bb0b4de5") + thenHeaderIsNotPresentOnRequest("some-header") + } + + @Test + fun `if there are no header attributes does not set headers on request`() { + givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) + + whenExtractingAttributes() + + thenRequestIsSet() + assertNull(fixture.scope.request!!.headers) + } + + @Test + fun `if there is no request method attribute does not set request on scope`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "io.sentry" + ) + ) + + whenExtractingAttributes() + + thenRequestIsNotSet() + } + private fun givenAttributes(map: Map, Any>) { map.forEach { k, v -> fixture.attributes.put(k, v) @@ -292,17 +342,21 @@ class OpenTelemetryAttributesExtractorTest { } private fun whenExtractingAttributes() { - OpenTelemetryAttributesExtractor().extract(fixture.spanData, fixture.sentrySpan, fixture.scope) + OpenTelemetryAttributesExtractor().extract(fixture.spanData, fixture.scope, fixture.options) } private fun whenExtractingUrl(): String? { - return OpenTelemetryAttributesExtractor().extractUrl(fixture.attributes) + return OpenTelemetryAttributesExtractor().extractUrl(fixture.attributes, fixture.options) } private fun thenRequestIsSet() { assertNotNull(fixture.scope.request) } + private fun thenRequestIsNotSet() { + assertNull(fixture.scope.request) + } + private fun thenUrlIsSetTo(expected: String) { assertEquals(expected, fixture.scope.request!!.url) } @@ -314,4 +368,12 @@ class OpenTelemetryAttributesExtractorTest { private fun thenQueryIsSetTo(expected: String) { assertEquals(expected, fixture.scope.request!!.queryString) } + + private fun thenHeaderIsPresentOnRequest(headerName: String, expectedValue: String) { + assertEquals(expectedValue, fixture.scope.request!!.headers!!.get(headerName)) + } + + private fun thenHeaderIsNotPresentOnRequest(headerName: String) { + assertFalse(fixture.scope.request!!.headers!!.containsKey(headerName)) + } } From f64d1f262140664c320e4228e6bc6cc25fed4b86 Mon Sep 17 00:00:00 2001 From: Isak Wertwein <5068689+aesy@users.noreply.github.com> Date: Wed, 26 Feb 2025 12:42:29 +0100 Subject: [PATCH 011/914] Add support for async dispatch requests (#3983) * Add support for async dispatch requests Keeps the transaction open until the response is committed. * fix distributed tracing for TwP * add option to opt into async handling * Copy changes to Spring Boot 2 * add tests; fix comments * additional comment * changelog --------- Co-authored-by: Alexander Dinauer Co-authored-by: Alexander Dinauer --- CHANGELOG.md | 2 + .../api/sentry-spring-boot-jakarta.api | 2 + .../boot/jakarta/SentryAutoConfiguration.java | 9 +- .../spring/boot/jakarta/SentryProperties.java | 12 ++ sentry-spring-boot/api/sentry-spring-boot.api | 2 + .../spring/boot/SentryAutoConfiguration.java | 9 +- .../sentry/spring/boot/SentryProperties.java | 12 ++ .../api/sentry-spring-jakarta.api | 2 + .../jakarta/tracing/SentryTracingFilter.java | 124 ++++++++++++++---- .../tracing/SentryTracingFilterTest.kt | 107 ++++++++++++++- sentry-spring/api/sentry-spring.api | 2 + .../spring/tracing/SentryTracingFilter.java | 124 ++++++++++++++---- .../spring/tracing/SentryTracingFilterTest.kt | 107 ++++++++++++++- 13 files changed, 458 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68eb9a2ec21..1fbd38a3f7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - Added `enableTraceIdGeneration` to the AndroidOptions. This allows Hybrid SDKs to "freeze" and control the trace and connect errors on different layers of the application ([4188](https://github.com/getsentry/sentry-java/pull/4188)) - Move to a single NetworkCallback listener to reduce number of IPC calls on Android ([#4164](https://github.com/getsentry/sentry-java/pull/4164)) - Add GraphQL Apollo Kotlin 4 integration ([#4166](https://github.com/getsentry/sentry-java/pull/4166)) +- Add support for async dispatch requests to Spring Boot 2 and 3 ([#3983](https://github.com/getsentry/sentry-java/pull/3983)) + - To enable it, please set `sentry.keep-transactions-open-for-async-responses=true` in `application.properties` or `sentry.keepTransactionsOpenForAsyncResponses: true` in `application.yml` - Add constructor to JUL `SentryHandler` for disabling external config ([#4208](https://github.com/getsentry/sentry-java/pull/4208)) ### Fixes diff --git a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api index ac89da6d379..cec2df120b5 100644 --- a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api +++ b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api @@ -32,10 +32,12 @@ public class io/sentry/spring/boot/jakarta/SentryProperties : io/sentry/SentryOp public fun getReactive ()Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive; public fun getUserFilterOrder ()Ljava/lang/Integer; public fun isEnableAotCompatibility ()Z + public fun isKeepTransactionsOpenForAsyncResponses ()Z public fun isUseGitCommitIdAsRelease ()Z public fun setEnableAotCompatibility (Z)V public fun setExceptionResolverOrder (I)V public fun setGraphql (Lio/sentry/spring/boot/jakarta/SentryProperties$Graphql;)V + public fun setKeepTransactionsOpenForAsyncResponses (Z)V public fun setLogging (Lio/sentry/spring/boot/jakarta/SentryProperties$Logging;)V public fun setReactive (Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive;)V public fun setUseGitCommitIdAsRelease (Z)V diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java index 811b8449df9..4ed9acf1c07 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java @@ -312,9 +312,14 @@ static class SentrySecurityConfiguration { @ConditionalOnMissingBean(name = "sentryTracingFilter") public FilterRegistrationBean sentryTracingFilter( final @NotNull IScopes scopes, - final @NotNull TransactionNameProvider transactionNameProvider) { + final @NotNull TransactionNameProvider transactionNameProvider, + final @NotNull SentryProperties sentryProperties) { FilterRegistrationBean filter = - new FilterRegistrationBean<>(new SentryTracingFilter(scopes, transactionNameProvider)); + new FilterRegistrationBean<>( + new SentryTracingFilter( + scopes, + transactionNameProvider, + sentryProperties.isKeepTransactionsOpenForAsyncResponses())); filter.setOrder(SENTRY_SPRING_FILTER_PRECEDENCE + 1); // must run after SentrySpringFilter return filter; } diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java index 80ea79932ca..b0778134bd4 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.event.Level; @@ -28,6 +29,8 @@ public class SentryProperties extends SentryOptions { */ private @Nullable Integer userFilterOrder; + @ApiStatus.Experimental private boolean keepTransactionsOpenForAsyncResponses = false; + /** Logging framework integration properties. */ private @NotNull Logging logging = new Logging(); @@ -104,6 +107,15 @@ public void setEnableAotCompatibility(boolean enableAotCompatibility) { this.enableAotCompatibility = enableAotCompatibility; } + public boolean isKeepTransactionsOpenForAsyncResponses() { + return keepTransactionsOpenForAsyncResponses; + } + + public void setKeepTransactionsOpenForAsyncResponses( + boolean keepTransactionsOpenForAsyncResponses) { + this.keepTransactionsOpenForAsyncResponses = keepTransactionsOpenForAsyncResponses; + } + public @NotNull Graphql getGraphql() { return graphql; } diff --git a/sentry-spring-boot/api/sentry-spring-boot.api b/sentry-spring-boot/api/sentry-spring-boot.api index 79b72bfb39f..b3ce0896e38 100644 --- a/sentry-spring-boot/api/sentry-spring-boot.api +++ b/sentry-spring-boot/api/sentry-spring-boot.api @@ -30,9 +30,11 @@ public class io/sentry/spring/boot/SentryProperties : io/sentry/SentryOptions { public fun getGraphql ()Lio/sentry/spring/boot/SentryProperties$Graphql; public fun getLogging ()Lio/sentry/spring/boot/SentryProperties$Logging; public fun getUserFilterOrder ()Ljava/lang/Integer; + public fun isKeepTransactionsOpenForAsyncResponses ()Z public fun isUseGitCommitIdAsRelease ()Z public fun setExceptionResolverOrder (I)V public fun setGraphql (Lio/sentry/spring/boot/SentryProperties$Graphql;)V + public fun setKeepTransactionsOpenForAsyncResponses (Z)V public fun setLogging (Lio/sentry/spring/boot/SentryProperties$Logging;)V public fun setUseGitCommitIdAsRelease (Z)V public fun setUserFilterOrder (Ljava/lang/Integer;)V diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index 9745c8a6e55..98cdc464877 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -297,9 +297,14 @@ static class SentrySecurityConfiguration { @ConditionalOnMissingBean(name = "sentryTracingFilter") public FilterRegistrationBean sentryTracingFilter( final @NotNull IScopes scopes, - final @NotNull TransactionNameProvider transactionNameProvider) { + final @NotNull TransactionNameProvider transactionNameProvider, + final @NotNull SentryProperties sentryProperties) { FilterRegistrationBean filter = - new FilterRegistrationBean<>(new SentryTracingFilter(scopes, transactionNameProvider)); + new FilterRegistrationBean<>( + new SentryTracingFilter( + scopes, + transactionNameProvider, + sentryProperties.isKeepTransactionsOpenForAsyncResponses())); filter.setOrder(SENTRY_SPRING_FILTER_PRECEDENCE + 1); // must run after SentrySpringFilter return filter; } diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java index 334e36f4024..e40700b6b63 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.event.Level; @@ -28,6 +29,8 @@ public class SentryProperties extends SentryOptions { */ private @Nullable Integer userFilterOrder; + @ApiStatus.Experimental private boolean keepTransactionsOpenForAsyncResponses = false; + /** Logging framework integration properties. */ private @NotNull Logging logging = new Logging(); @@ -70,6 +73,15 @@ public void setUserFilterOrder(final @Nullable Integer userFilterOrder) { this.userFilterOrder = userFilterOrder; } + public boolean isKeepTransactionsOpenForAsyncResponses() { + return keepTransactionsOpenForAsyncResponses; + } + + public void setKeepTransactionsOpenForAsyncResponses( + boolean keepTransactionsOpenForAsyncResponses) { + this.keepTransactionsOpenForAsyncResponses = keepTransactionsOpenForAsyncResponses; + } + public @NotNull Logging getLogging() { return logging; } diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index 4b942b833d2..4140cd0a59d 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -264,7 +264,9 @@ public class io/sentry/spring/jakarta/tracing/SentryTracingFilter : org/springfr public fun ()V public fun (Lio/sentry/IScopes;)V public fun (Lio/sentry/IScopes;Lio/sentry/spring/jakarta/tracing/TransactionNameProvider;)V + public fun (Lio/sentry/IScopes;Lio/sentry/spring/jakarta/tracing/TransactionNameProvider;Z)V protected fun doFilterInternal (Ljakarta/servlet/http/HttpServletRequest;Ljakarta/servlet/http/HttpServletResponse;Ljakarta/servlet/FilterChain;)V + protected fun shouldNotFilterAsyncDispatch ()Z } public abstract interface annotation class io/sentry/spring/jakarta/tracing/SentryTransaction : java/lang/annotation/Annotation { diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTracingFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTracingFilter.java index bd1ffbab812..f740ce4e612 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTracingFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTracingFilter.java @@ -37,9 +37,11 @@ public class SentryTracingFilter extends OncePerRequestFilter { private static final String TRANSACTION_OP = "http.server"; private static final String TRACE_ORIGIN = "auto.http.spring_jakarta.webmvc"; + private static final String TRANSACTION_ATTR = "sentry.transaction"; private final @NotNull TransactionNameProvider transactionNameProvider; private final @NotNull IScopes scopes; + private final boolean isAsyncSupportEnabled; /** * Creates filter that resolves transaction name using {@link SpringMvcTransactionNameProvider}. @@ -63,15 +65,37 @@ public SentryTracingFilter() { public SentryTracingFilter( final @NotNull IScopes scopes, final @NotNull TransactionNameProvider transactionNameProvider) { + this(scopes, transactionNameProvider, false); + } + + /** + * Creates filter that resolves transaction name using transaction name provider given by + * parameter. + * + * @param scopes - the scopes + * @param transactionNameProvider - transaction name provider. + * @param isAsyncSupportEnabled - whether transactions should be kept open until async handling is + * done + */ + public SentryTracingFilter( + final @NotNull IScopes scopes, + final @NotNull TransactionNameProvider transactionNameProvider, + final boolean isAsyncSupportEnabled) { this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.transactionNameProvider = Objects.requireNonNull(transactionNameProvider, "transactionNameProvider is required"); + this.isAsyncSupportEnabled = isAsyncSupportEnabled; } public SentryTracingFilter(final @NotNull IScopes scopes) { this(scopes, new SpringMvcTransactionNameProvider()); } + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return !isAsyncSupportEnabled; + } + @Override protected void doFilterInternal( final @NotNull HttpServletRequest httpRequest, @@ -79,12 +103,14 @@ protected void doFilterInternal( final @NotNull FilterChain filterChain) throws ServletException, IOException { if (scopes.isEnabled() && !isIgnored()) { - final @Nullable String sentryTraceHeader = - httpRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER); - final @Nullable List baggageHeader = - Collections.list(httpRequest.getHeaders(BaggageHeader.BAGGAGE_HEADER)); - final @Nullable TransactionContext transactionContext = - scopes.continueTrace(sentryTraceHeader, baggageHeader); + @Nullable TransactionContext transactionContext = null; + if (shouldContinueTrace(httpRequest)) { + final @Nullable String sentryTraceHeader = + httpRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER); + final @Nullable List baggageHeader = + Collections.list(httpRequest.getHeaders(BaggageHeader.BAGGAGE_HEADER)); + transactionContext = scopes.continueTrace(sentryTraceHeader, baggageHeader); + } if (scopes.getOptions().isTracingEnabled() && shouldTraceRequest(httpRequest)) { doFilterWithTransaction(httpRequest, httpResponse, filterChain, transactionContext); } else { @@ -105,35 +131,85 @@ private void doFilterWithTransaction( FilterChain filterChain, final @Nullable TransactionContext transactionContext) throws IOException, ServletException { - // at this stage we are not able to get real transaction name - final ITransaction transaction = startTransaction(httpRequest, transactionContext); + final @Nullable ITransaction transaction = + getOrStartTransaction(httpRequest, transactionContext); try { filterChain.doFilter(httpRequest, httpResponse); } catch (Throwable e) { - // exceptions that are not handled by Spring - transaction.setStatus(SpanStatus.INTERNAL_ERROR); + if (transaction != null) { + // exceptions that are not handled by Spring + transaction.setStatus(SpanStatus.INTERNAL_ERROR); + } throw e; } finally { - // after all filters run, templated path pattern is available in request attribute - final String transactionName = transactionNameProvider.provideTransactionName(httpRequest); - final TransactionNameSource transactionNameSource = - transactionNameProvider.provideTransactionSource(); - // if transaction name is not resolved, the request has not been processed by a controller - // and we should not report it to Sentry - if (transactionName != null) { - transaction.setName(transactionName, transactionNameSource); - transaction.setOperation(TRANSACTION_OP); - // if exception has been thrown, transaction status is already set to INTERNAL_ERROR, and - // httpResponse.getStatus() returns 200. - if (transaction.getStatus() == null) { - transaction.setStatus(SpanStatus.fromHttpStatusCode(httpResponse.getStatus())); + if (shouldFinishTransaction(httpRequest) && transaction != null) { + // after all filters run, templated path pattern is available in request attribute + final String transactionName = transactionNameProvider.provideTransactionName(httpRequest); + final TransactionNameSource transactionNameSource = + transactionNameProvider.provideTransactionSource(); + // if transaction name is not resolved, the request has not been processed by a controller + // and we should not report it to Sentry + if (transactionName != null) { + transaction.setName(transactionName, transactionNameSource); + transaction.setOperation(TRANSACTION_OP); + // if exception has been thrown, transaction status is already set to INTERNAL_ERROR, and + // httpResponse.getStatus() returns 200. + if (transaction.getStatus() == null) { + transaction.setStatus(SpanStatus.fromHttpStatusCode(httpResponse.getStatus())); + } + transaction.finish(); } - transaction.finish(); } } } + private ITransaction getOrStartTransaction( + final @NotNull HttpServletRequest httpRequest, + final @Nullable TransactionContext transactionContext) { + if (isAsyncDispatch(httpRequest)) { + // second invocation of this filter for the same async request already has the transaction + // in the attributes + return (ITransaction) httpRequest.getAttribute(TRANSACTION_ATTR); + } else { + // at this stage we are not able to get real transaction name + final @NotNull ITransaction transaction = startTransaction(httpRequest, transactionContext); + if (shouldStoreTransactionForAsyncProcessing()) { + httpRequest.setAttribute(TRANSACTION_ATTR, transaction); + } + return transaction; + } + } + + /** + * Returns false if an async request is being dispatched (second invocation of the filter for the + * same async request). + * + *

Returns true if not an async request or this is the first invocation of the filter for the + * same async request + */ + private boolean shouldContinueTrace(HttpServletRequest httpRequest) { + return !isAsyncSupportEnabled || !isAsyncDispatch(httpRequest); + } + + private boolean shouldStoreTransactionForAsyncProcessing() { + return isAsyncSupportEnabled; + } + + /** + * Returns false if async request handling has only been started but not yet finished (first + * invocation of this filter for the same async request). + * + *

Returns true if not an async request or async request handling has finished (second + * invocation of this filter for the same async request) + * + *

Note: isAsyncStarted changes its return value after filterChain.doFilter() of the first + * async invocation + */ + private boolean shouldFinishTransaction(HttpServletRequest httpRequest) { + return !isAsyncSupportEnabled || !isAsyncStarted(httpRequest); + } + private boolean shouldTraceRequest(final @NotNull HttpServletRequest request) { return scopes.getOptions().isTraceOptionsRequests() || !HttpMethod.OPTIONS.name().equals(request.getMethod()); diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt index ff0020ddfeb..14bf0969035 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt @@ -13,12 +13,15 @@ import io.sentry.TransactionOptions import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction import io.sentry.protocol.TransactionNameSource +import jakarta.servlet.DispatcherType import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import org.assertj.core.api.Assertions.assertThat +import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -29,6 +32,8 @@ import org.mockito.kotlin.whenever import org.springframework.http.HttpMethod import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.web.context.request.async.AsyncWebRequest +import org.springframework.web.context.request.async.WebAsyncUtils import org.springframework.web.servlet.HandlerMapping import kotlin.test.Test import kotlin.test.assertEquals @@ -47,13 +52,14 @@ class SentryTracingFilterTest { dsn = "https://key@sentry.io/proj" tracesSampleRate = 1.0 } + val asyncRequest = mock() val logger = mock() init { whenever(scopes.options).thenReturn(options) } - fun getSut(isEnabled: Boolean = true, status: Int = 200, sentryTraceHeader: String? = null, baggageHeaders: List? = null): SentryTracingFilter { + fun getSut(isEnabled: Boolean = true, status: Int = 200, sentryTraceHeader: String? = null, baggageHeaders: List? = null, isAsyncSupportEnabled: Boolean = false): SentryTracingFilter { request.requestURI = "/product/12" request.method = "POST" request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/product/{id}") @@ -70,7 +76,7 @@ class SentryTracingFilterTest { whenever(scopes.startTransaction(any(), check { assertTrue(it.isBindToScope) })).thenAnswer { SentryTracer(it.arguments[0] as TransactionContext, scopes) } whenever(scopes.isEnabled).thenReturn(isEnabled) whenever(scopes.continueTrace(any(), any())).thenAnswer { TransactionContext.fromPropagationContext(PropagationContext.fromHeaders(logger, it.arguments[0] as String?, it.arguments[1] as List?)) } - return SentryTracingFilter(scopes, transactionNameProvider) + return SentryTracingFilter(scopes, transactionNameProvider, isAsyncSupportEnabled) } } @@ -307,4 +313,101 @@ class SentryTracingFilterTest { anyOrNull() ) } + + @Test + fun `creates transaction around async request`() { + val sentryTrace = "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1" + val baggage = listOf("baggage: sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d") + val filter = fixture.getSut(sentryTraceHeader = sentryTrace, baggageHeaders = baggage, isAsyncSupportEnabled = true) + + val asyncChain = mock() + doAnswer { + val request = it.arguments.first() as MockHttpServletRequest + whenever(fixture.asyncRequest.isAsyncStarted).thenReturn(true) + WebAsyncUtils.getAsyncManager(request).setAsyncWebRequest(fixture.asyncRequest) + }.whenever(asyncChain).doFilter(any(), any()) + + filter.doFilter(fixture.request, fixture.response, asyncChain) + + verify(fixture.scopes).continueTrace(eq(sentryTrace), eq(baggage)) + verify(fixture.scopes).startTransaction( + check { + assertEquals("POST /product/12", it.name) + assertEquals(TransactionNameSource.URL, it.transactionNameSource) + assertEquals("http.server", it.operation) + }, + check { + assertNotNull(it.customSamplingContext?.get("request")) + assertTrue(it.customSamplingContext?.get("request") is HttpServletRequest) + assertTrue(it.isBindToScope) + assertThat(it.origin).isEqualTo("auto.http.spring_jakarta.webmvc") + } + ) + verify(asyncChain).doFilter(fixture.request, fixture.response) + verify(fixture.scopes, never()).captureTransaction( + any(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + + Mockito.clearInvocations(fixture.scopes) + + fixture.request.dispatcherType = DispatcherType.ASYNC + whenever(fixture.asyncRequest.isAsyncStarted).thenReturn(false) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes, never()).startTransaction(anyOrNull(), anyOrNull()) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes).captureTransaction( + check { + assertThat(it.transaction).isEqualTo("POST /product/{id}") + assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) + assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + verify(fixture.scopes, never()).continueTrace(anyOrNull(), anyOrNull()) + } + + @Test + fun `creates and finishes transaction immediately for async request if handling disabled`() { + val sentryTrace = "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1" + val baggage = listOf("baggage: sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d") + val filter = fixture.getSut(sentryTraceHeader = sentryTrace, baggageHeaders = baggage, isAsyncSupportEnabled = false) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).startTransaction( + check { + assertEquals("POST /product/12", it.name) + assertEquals(TransactionNameSource.URL, it.transactionNameSource) + assertEquals("http.server", it.operation) + }, + check { + assertNotNull(it.customSamplingContext?.get("request")) + assertTrue(it.customSamplingContext?.get("request") is HttpServletRequest) + assertTrue(it.isBindToScope) + assertThat(it.origin).isEqualTo("auto.http.spring_jakarta.webmvc") + } + ) + verify(fixture.scopes).continueTrace(eq(sentryTrace), eq(baggage)) + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes).captureTransaction( + check { + assertThat(it.transaction).isEqualTo("POST /product/{id}") + assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) + assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } } diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index 67ad0c1c963..4743c32284b 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -255,7 +255,9 @@ public class io/sentry/spring/tracing/SentryTracingFilter : org/springframework/ public fun ()V public fun (Lio/sentry/IScopes;)V public fun (Lio/sentry/IScopes;Lio/sentry/spring/tracing/TransactionNameProvider;)V + public fun (Lio/sentry/IScopes;Lio/sentry/spring/tracing/TransactionNameProvider;Z)V protected fun doFilterInternal (Ljavax/servlet/http/HttpServletRequest;Ljavax/servlet/http/HttpServletResponse;Ljavax/servlet/FilterChain;)V + protected fun shouldNotFilterAsyncDispatch ()Z } public abstract interface annotation class io/sentry/spring/tracing/SentryTransaction : java/lang/annotation/Annotation { diff --git a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTracingFilter.java b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTracingFilter.java index 8f228f80b72..6c83eb2fb4f 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTracingFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTracingFilter.java @@ -34,9 +34,11 @@ public class SentryTracingFilter extends OncePerRequestFilter { private static final String TRANSACTION_OP = "http.server"; private static final String TRACE_ORIGIN = "auto.http.spring.webmvc"; + private static final String TRANSACTION_ATTR = "sentry.transaction"; private final @NotNull TransactionNameProvider transactionNameProvider; private final @NotNull IScopes scopes; + private final boolean isAsyncSupportEnabled; /** * Creates filter that resolves transaction name using {@link SpringMvcTransactionNameProvider}. @@ -60,15 +62,37 @@ public SentryTracingFilter() { public SentryTracingFilter( final @NotNull IScopes scopes, final @NotNull TransactionNameProvider transactionNameProvider) { + this(scopes, transactionNameProvider, false); + } + + /** + * Creates filter that resolves transaction name using transaction name provider given by + * parameter. + * + * @param scopes - the scopes + * @param transactionNameProvider - transaction name provider. + * @param isAsyncSupportEnabled - whether transactions should be kept open until async handling is + * done + */ + public SentryTracingFilter( + final @NotNull IScopes scopes, + final @NotNull TransactionNameProvider transactionNameProvider, + final boolean isAsyncSupportEnabled) { this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.transactionNameProvider = Objects.requireNonNull(transactionNameProvider, "transactionNameProvider is required"); + this.isAsyncSupportEnabled = isAsyncSupportEnabled; } public SentryTracingFilter(final @NotNull IScopes scopes) { this(scopes, new SpringMvcTransactionNameProvider()); } + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return !isAsyncSupportEnabled; + } + @Override protected void doFilterInternal( final @NotNull HttpServletRequest httpRequest, @@ -77,12 +101,14 @@ protected void doFilterInternal( throws ServletException, IOException { if (scopes.isEnabled() && !isIgnored()) { - final @Nullable String sentryTraceHeader = - httpRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER); - final @Nullable List baggageHeader = - Collections.list(httpRequest.getHeaders(BaggageHeader.BAGGAGE_HEADER)); - final @Nullable TransactionContext transactionContext = - scopes.continueTrace(sentryTraceHeader, baggageHeader); + @Nullable TransactionContext transactionContext = null; + if (shouldContinueTrace(httpRequest)) { + final @Nullable String sentryTraceHeader = + httpRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER); + final @Nullable List baggageHeader = + Collections.list(httpRequest.getHeaders(BaggageHeader.BAGGAGE_HEADER)); + transactionContext = scopes.continueTrace(sentryTraceHeader, baggageHeader); + } if (scopes.getOptions().isTracingEnabled() && shouldTraceRequest(httpRequest)) { doFilterWithTransaction(httpRequest, httpResponse, filterChain, transactionContext); @@ -104,35 +130,85 @@ private void doFilterWithTransaction( FilterChain filterChain, final @Nullable TransactionContext transactionContext) throws IOException, ServletException { - // at this stage we are not able to get real transaction name - final ITransaction transaction = startTransaction(httpRequest, transactionContext); + final @Nullable ITransaction transaction = + getOrStartTransaction(httpRequest, transactionContext); try { filterChain.doFilter(httpRequest, httpResponse); } catch (Throwable e) { - // exceptions that are not handled by Spring - transaction.setStatus(SpanStatus.INTERNAL_ERROR); + if (transaction != null) { + // exceptions that are not handled by Spring + transaction.setStatus(SpanStatus.INTERNAL_ERROR); + } throw e; } finally { - // after all filters run, templated path pattern is available in request attribute - final String transactionName = transactionNameProvider.provideTransactionName(httpRequest); - final TransactionNameSource transactionNameSource = - transactionNameProvider.provideTransactionSource(); - // if transaction name is not resolved, the request has not been processed by a controller - // and we should not report it to Sentry - if (transactionName != null) { - transaction.setName(transactionName, transactionNameSource); - transaction.setOperation(TRANSACTION_OP); - // if exception has been thrown, transaction status is already set to INTERNAL_ERROR, and - // httpResponse.getStatus() returns 200. - if (transaction.getStatus() == null) { - transaction.setStatus(SpanStatus.fromHttpStatusCode(httpResponse.getStatus())); + if (shouldFinishTransaction(httpRequest) && transaction != null) { + // after all filters run, templated path pattern is available in request attribute + final String transactionName = transactionNameProvider.provideTransactionName(httpRequest); + final TransactionNameSource transactionNameSource = + transactionNameProvider.provideTransactionSource(); + // if transaction name is not resolved, the request has not been processed by a controller + // and we should not report it to Sentry + if (transactionName != null) { + transaction.setName(transactionName, transactionNameSource); + transaction.setOperation(TRANSACTION_OP); + // if exception has been thrown, transaction status is already set to INTERNAL_ERROR, and + // httpResponse.getStatus() returns 200. + if (transaction.getStatus() == null) { + transaction.setStatus(SpanStatus.fromHttpStatusCode(httpResponse.getStatus())); + } + transaction.finish(); } - transaction.finish(); } } } + private ITransaction getOrStartTransaction( + final @NotNull HttpServletRequest httpRequest, + final @Nullable TransactionContext transactionContext) { + if (isAsyncDispatch(httpRequest)) { + // second invocation of this filter for the same async request already has the transaction + // in the attributes + return (ITransaction) httpRequest.getAttribute(TRANSACTION_ATTR); + } else { + // at this stage we are not able to get real transaction name + final @NotNull ITransaction transaction = startTransaction(httpRequest, transactionContext); + if (shouldStoreTransactionForAsyncProcessing()) { + httpRequest.setAttribute(TRANSACTION_ATTR, transaction); + } + return transaction; + } + } + + /** + * Returns false if an async request is being dispatched (second invocation of the filter for the + * same async request). + * + *

Returns true if not an async request or this is the first invocation of the filter for the + * same async request + */ + private boolean shouldContinueTrace(HttpServletRequest httpRequest) { + return !isAsyncSupportEnabled || !isAsyncDispatch(httpRequest); + } + + private boolean shouldStoreTransactionForAsyncProcessing() { + return isAsyncSupportEnabled; + } + + /** + * Returns false if async request handling has only been started but not yet finished (first + * invocation of this filter for the same async request). + * + *

Returns true if not an async request or async request handling has finished (second + * invocation of this filter for the same async request) + * + *

Note: isAsyncStarted changes its return value after filterChain.doFilter() of the first + * async invocation + */ + private boolean shouldFinishTransaction(HttpServletRequest httpRequest) { + return !isAsyncSupportEnabled || !isAsyncStarted(httpRequest); + } + private boolean shouldTraceRequest(final @NotNull HttpServletRequest request) { return scopes.getOptions().isTraceOptionsRequests() || !HttpMethod.OPTIONS.name().equals(request.getMethod()); diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt index 538ac7c8cc6..9270d9b0c8a 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt @@ -14,9 +14,11 @@ import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction import io.sentry.protocol.TransactionNameSource import org.assertj.core.api.Assertions.assertThat +import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -27,7 +29,10 @@ import org.mockito.kotlin.whenever import org.springframework.http.HttpMethod import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.web.context.request.async.AsyncWebRequest +import org.springframework.web.context.request.async.WebAsyncUtils import org.springframework.web.servlet.HandlerMapping +import javax.servlet.DispatcherType import javax.servlet.FilterChain import javax.servlet.http.HttpServletRequest import kotlin.test.Test @@ -48,12 +53,13 @@ class SentryTracingFilterTest { tracesSampleRate = 1.0 } val logger = mock() + val asyncRequest = mock() init { whenever(scopes.options).thenReturn(options) } - fun getSut(isEnabled: Boolean = true, status: Int = 200, sentryTraceHeader: String? = null, baggageHeaders: List? = null): SentryTracingFilter { + fun getSut(isEnabled: Boolean = true, status: Int = 200, sentryTraceHeader: String? = null, baggageHeaders: List? = null, isAsyncSupportEnabled: Boolean = false): SentryTracingFilter { request.requestURI = "/product/12" request.method = "POST" request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/product/{id}") @@ -70,7 +76,7 @@ class SentryTracingFilterTest { whenever(scopes.startTransaction(any(), check { assertTrue(it.isBindToScope) })).thenAnswer { SentryTracer(it.arguments[0] as TransactionContext, scopes) } whenever(scopes.isEnabled).thenReturn(isEnabled) whenever(scopes.continueTrace(any(), any())).thenAnswer { TransactionContext.fromPropagationContext(PropagationContext.fromHeaders(logger, it.arguments[0] as String?, it.arguments[1] as List?)) } - return SentryTracingFilter(scopes, transactionNameProvider) + return SentryTracingFilter(scopes, transactionNameProvider, isAsyncSupportEnabled) } } @@ -307,4 +313,101 @@ class SentryTracingFilterTest { anyOrNull() ) } + + @Test + fun `creates transaction around async request`() { + val sentryTrace = "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1" + val baggage = listOf("baggage: sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d") + val filter = fixture.getSut(sentryTraceHeader = sentryTrace, baggageHeaders = baggage, isAsyncSupportEnabled = true) + + val asyncChain = mock() + doAnswer { + val request = it.arguments.first() as MockHttpServletRequest + whenever(fixture.asyncRequest.isAsyncStarted).thenReturn(true) + WebAsyncUtils.getAsyncManager(request).setAsyncWebRequest(fixture.asyncRequest) + }.whenever(asyncChain).doFilter(any(), any()) + + filter.doFilter(fixture.request, fixture.response, asyncChain) + + verify(fixture.scopes).continueTrace(eq(sentryTrace), eq(baggage)) + verify(fixture.scopes).startTransaction( + check { + assertEquals("POST /product/12", it.name) + assertEquals(TransactionNameSource.URL, it.transactionNameSource) + assertEquals("http.server", it.operation) + }, + check { + assertNotNull(it.customSamplingContext?.get("request")) + assertTrue(it.customSamplingContext?.get("request") is HttpServletRequest) + assertTrue(it.isBindToScope) + assertThat(it.origin).isEqualTo("auto.http.spring.webmvc") + } + ) + verify(asyncChain).doFilter(fixture.request, fixture.response) + verify(fixture.scopes, never()).captureTransaction( + any(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + + Mockito.clearInvocations(fixture.scopes) + + fixture.request.dispatcherType = DispatcherType.ASYNC + whenever(fixture.asyncRequest.isAsyncStarted).thenReturn(false) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes, never()).startTransaction(anyOrNull(), anyOrNull()) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes).captureTransaction( + check { + assertThat(it.transaction).isEqualTo("POST /product/{id}") + assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) + assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + verify(fixture.scopes, never()).continueTrace(anyOrNull(), anyOrNull()) + } + + @Test + fun `creates and finishes transaction immediately for async request if handling disabled`() { + val sentryTrace = "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1" + val baggage = listOf("baggage: sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d") + val filter = fixture.getSut(sentryTraceHeader = sentryTrace, baggageHeaders = baggage, isAsyncSupportEnabled = false) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).startTransaction( + check { + assertEquals("POST /product/12", it.name) + assertEquals(TransactionNameSource.URL, it.transactionNameSource) + assertEquals("http.server", it.operation) + }, + check { + assertNotNull(it.customSamplingContext?.get("request")) + assertTrue(it.customSamplingContext?.get("request") is HttpServletRequest) + assertTrue(it.isBindToScope) + assertThat(it.origin).isEqualTo("auto.http.spring.webmvc") + } + ) + verify(fixture.scopes).continueTrace(eq(sentryTrace), eq(baggage)) + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes).captureTransaction( + check { + assertThat(it.transaction).isEqualTo("POST /product/{id}") + assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) + assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + }, + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + } } From c1a567b4d256e74d74580b87589ed1234d1ea53b Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 26 Feb 2025 14:56:17 +0100 Subject: [PATCH 012/914] Filter strings that cannot be parsed as Regex no longer cause an SDK crash (#4213) * Fix exception when creating FilterString from string that cannot be parsed as regex * changelog --- CHANGELOG.md | 3 +++ .../src/main/java/io/sentry/FilterString.java | 20 ++++++++++++++-- .../test/java/io/sentry/FilterStringTest.kt | 24 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/FilterStringTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fbd38a3f7b..4e6c20beb2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ ### Fixes +- Filter strings that cannot be parsed as Regex no longer cause an SDK crash ([#4213](https://github.com/getsentry/sentry-java/pull/4213)) + - This was the case e.g. for `ignoredErrors`, `ignoredTransactions` and `ignoredCheckIns` + - We now simply don't use such strings for Regex matching and only use them for String comparison - `SentryOptions.setTracePropagationTargets` is no longer marked internal ([#4170](https://github.com/getsentry/sentry-java/pull/4170)) - Session Replay: Fix crash when a navigation breadcrumb does not have "to" destination ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) - Session Replay: Cap video segment duration to maximum 5 minutes to prevent endless video encoding in background ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) diff --git a/sentry/src/main/java/io/sentry/FilterString.java b/sentry/src/main/java/io/sentry/FilterString.java index 8dd5f479492..3fd146aa4eb 100644 --- a/sentry/src/main/java/io/sentry/FilterString.java +++ b/sentry/src/main/java/io/sentry/FilterString.java @@ -3,14 +3,27 @@ import java.util.Objects; import java.util.regex.Pattern; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public final class FilterString { private final @NotNull String filterString; - private final @NotNull Pattern pattern; + private final @Nullable Pattern pattern; public FilterString(@NotNull String filterString) { this.filterString = filterString; - this.pattern = Pattern.compile(filterString); + @Nullable Pattern pattern = null; + try { + pattern = Pattern.compile(filterString); + } catch (Throwable t) { + Sentry.getCurrentScopes() + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Only using filter string for String comparison as it could not be parsed as regex: %s", + filterString); + } + this.pattern = pattern; } public @NotNull String getFilterString() { @@ -18,6 +31,9 @@ public FilterString(@NotNull String filterString) { } public boolean matches(String input) { + if (pattern == null) { + return false; + } return pattern.matcher(input).matches(); } diff --git a/sentry/src/test/java/io/sentry/FilterStringTest.kt b/sentry/src/test/java/io/sentry/FilterStringTest.kt new file mode 100644 index 00000000000..a253a91eb89 --- /dev/null +++ b/sentry/src/test/java/io/sentry/FilterStringTest.kt @@ -0,0 +1,24 @@ +package io.sentry + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FilterStringTest { + + @Test + fun `turns string into pattern`() { + val filterString = FilterString(".*") + assertTrue(filterString.matches("anything")) + assertEquals(".*", filterString.filterString) + } + + @Test + fun `skips pattern if not a valid regex`() { + // does not throw if the string is not a valid pattern + val filterString = FilterString("I love my mustache {") + assertFalse(filterString.matches("I love my mustache {")) + assertEquals("I love my mustache {", filterString.filterString) + } +} From d5289e7ceec261040379fdc7a15bb83d7e7d37a8 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Wed, 26 Feb 2025 13:57:36 +0000 Subject: [PATCH 013/914] release: 8.3.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6c20beb2a..8b7c2efd7a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.3.0 ### Features diff --git a/gradle.properties b/gradle.properties index 5087fc4c957..9a32045706a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ org.gradle.workers.max=2 android.useAndroidX=true # Release information -versionName=8.2.0 +versionName=8.3.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 158d6889e5a1251c2dd5dc566869594f12c80004 Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Mon, 3 Mar 2025 09:54:44 +0100 Subject: [PATCH 014/914] feat: Allow Hybrid SDK to `setTrace` (#4137) --- CHANGELOG.md | 10 +++++++++ buildSrc/src/main/java/Config.kt | 2 +- .../api/sentry-android-core.api | 1 + .../android/core/InternalSentrySdk.java | 21 ++++++++++++++++++ .../android/core/InternalSentrySdkTest.kt | 22 +++++++++++++++++++ sentry-android-ndk/api/sentry-android-ndk.api | 1 + .../sentry/android/ndk/NdkScopeObserver.java | 20 +++++++++++++++++ sentry/api/sentry.api | 2 ++ .../java/io/sentry/PropagationContext.java | 13 +++++++++++ .../java/io/sentry/util/TracingUtils.java | 11 ++++++++++ 10 files changed, 102 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7c2efd7a0..b7376d03922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +### Features + +- The SDK now automatically propagates the trace-context to the native layer. This allows to connect errors on different layers of the application. ([#4137](https://github.com/getsentry/sentry-java/pull/4137)) + +### Dependencies + +- Bump Native SDK from v0.7.20 to v0.8.1 ([#4137](https://github.com/getsentry/sentry-java/pull/4137)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0810) + - [diff](https://github.com/getsentry/sentry-native/compare/v0.7.20...0.8.1) + ## 8.3.0 ### Features diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 0a3c62a1555..03b6849a6ba 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -159,7 +159,7 @@ object Config { val apolloKotlin = "com.apollographql.apollo3:apollo-runtime:3.8.2" val apolloKotlin4 = "com.apollographql.apollo:apollo-runtime:4.1.1" - val sentryNativeNdk = "io.sentry:sentry-native-ndk:0.7.20" + val sentryNativeNdk = "io.sentry:sentry-native-ndk:0.8.1" object OpenTelemetry { val otelVersion = "1.44.1" diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 59caf171564..85197f80380 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -221,6 +221,7 @@ public final class io/sentry/android/core/InternalSentrySdk { public static fun getAppStartMeasurement ()Ljava/util/Map; public static fun getCurrentScope ()Lio/sentry/IScope; public static fun serializeScope (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/IScope;)Ljava/util/Map; + public static fun setTrace (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;)V } public final class io/sentry/android/core/LoadClass : io/sentry/util/LoadClass { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 841edf8109a..cae558f0d43 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -1,5 +1,6 @@ package io.sentry.android.core; +import static io.sentry.Sentry.getCurrentScopes; import static io.sentry.SentryLevel.DEBUG; import static io.sentry.SentryLevel.INFO; import static io.sentry.SentryLevel.WARNING; @@ -13,6 +14,7 @@ import io.sentry.IScopes; import io.sentry.ISerializer; import io.sentry.ObjectWriter; +import io.sentry.PropagationContext; import io.sentry.ScopeType; import io.sentry.ScopesAdapter; import io.sentry.SentryEnvelope; @@ -30,6 +32,7 @@ import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import io.sentry.util.MapObjectWriter; +import io.sentry.util.TracingUtils; import java.io.ByteArrayInputStream; import java.io.File; import java.io.InputStream; @@ -329,4 +332,22 @@ private static Session updateSession( }); return sessionRef.get(); } + + /** + * Allows a Hybrid SDK to set the trace on the native layer + * + * @param traceId the trace ID + * @param spanId the trace origin's span ID + * @param sampleRate the sample rate used by the origin of the trace + * @param sampleRand the random value used to sample with by the origin of the trace + */ + public static void setTrace( + final @NotNull String traceId, + final @NotNull String spanId, + final @Nullable Double sampleRate, + final @Nullable Double sampleRand) { + TracingUtils.setTrace( + getCurrentScopes(), + PropagationContext.fromExistingTrace(traceId, spanId, sampleRate, sampleRand)); + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index 01b9845a9fe..7ddabb84ea7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -19,6 +19,7 @@ import io.sentry.SentryExceptionFactory import io.sentry.SentryItemType import io.sentry.SentryOptions import io.sentry.Session +import io.sentry.SpanId import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics import io.sentry.exception.ExceptionMechanismException @@ -505,4 +506,25 @@ class InternalSentrySdkTest { assertEquals(20.toLong(), actualProcessSpan["start_timestamp_ms"]) assertEquals(100.toLong(), actualProcessSpan["end_timestamp_ms"]) } + + @Test + fun `setTrace sets correct propagation context`() { + val fixture = Fixture() + fixture.init(context) + + val traceId = "771a43a4192642f0b136d5159a501700" + val spanId = "771a43a4192642f0" + val sampleRate = 0.5 + val sampleRand = 0.3 + + InternalSentrySdk.setTrace(traceId, spanId, sampleRate, sampleRand) + + Sentry.configureScope { scope -> + val propagationContext = scope.propagationContext + assertEquals(SentryId(traceId), propagationContext.traceId) + assertEquals(SpanId(spanId), propagationContext.parentSpanId) + assertEquals(sampleRate, propagationContext.baggage.sampleRateDouble) + assertEquals(sampleRand, propagationContext.baggage.sampleRandDouble) + } + } } diff --git a/sentry-android-ndk/api/sentry-android-ndk.api b/sentry-android-ndk/api/sentry-android-ndk.api index eb5f48a9bd9..44c153a71fe 100644 --- a/sentry-android-ndk/api/sentry-android-ndk.api +++ b/sentry-android-ndk/api/sentry-android-ndk.api @@ -20,6 +20,7 @@ public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObser public fun removeTag (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V + public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public fun setUser (Lio/sentry/protocol/User;)V } diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java index 118b1f68511..023ce965f51 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java @@ -2,9 +2,11 @@ import io.sentry.Breadcrumb; import io.sentry.DateUtils; +import io.sentry.IScope; import io.sentry.ScopeObserverAdapter; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.SpanContext; import io.sentry.ndk.INativeScope; import io.sentry.ndk.NativeScope; import io.sentry.protocol.User; @@ -125,4 +127,22 @@ public void removeExtra(final @NotNull String key) { .log(SentryLevel.ERROR, e, "Scope sync removeExtra(%s) has an error.", key); } } + + @Override + public void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope) { + if (spanContext == null) { + return; + } + + try { + options + .getExecutorService() + .submit( + () -> + nativeScope.setTrace( + spanContext.getTraceId().toString(), spanContext.getSpanId().toString())); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, e, "Scope sync setTrace failed."); + } + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 82e18268c99..1c4883e078e 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1952,6 +1952,7 @@ public final class io/sentry/PropagationContext { public fun ()V public fun (Lio/sentry/PropagationContext;)V public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Lio/sentry/SpanId;Lio/sentry/Baggage;Ljava/lang/Boolean;)V + public static fun fromExistingTrace (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;)Lio/sentry/PropagationContext; public static fun fromHeaders (Lio/sentry/ILogger;Ljava/lang/String;Ljava/lang/String;)Lio/sentry/PropagationContext; public static fun fromHeaders (Lio/sentry/ILogger;Ljava/lang/String;Ljava/util/List;)Lio/sentry/PropagationContext; public static fun fromHeaders (Lio/sentry/SentryTraceHeader;Lio/sentry/Baggage;Lio/sentry/SpanId;)Lio/sentry/PropagationContext; @@ -6360,6 +6361,7 @@ public final class io/sentry/util/TracingUtils { public static fun ensureBaggage (Lio/sentry/Baggage;Ljava/lang/Boolean;Ljava/lang/Double;Ljava/lang/Double;)Lio/sentry/Baggage; public static fun isIgnored (Ljava/util/List;Ljava/lang/String;)Z public static fun maybeUpdateBaggage (Lio/sentry/IScope;Lio/sentry/SentryOptions;)Lio/sentry/PropagationContext; + public static fun setTrace (Lio/sentry/IScopes;Lio/sentry/PropagationContext;)V public static fun startNewTrace (Lio/sentry/IScopes;)V public static fun trace (Lio/sentry/IScopes;Ljava/util/List;Lio/sentry/ISpan;)Lio/sentry/util/TracingUtils$TracingHeaders; public static fun traceIfAllowed (Lio/sentry/IScopes;Ljava/lang/String;Ljava/util/List;Lio/sentry/ISpan;)Lio/sentry/util/TracingUtils$TracingHeaders; diff --git a/sentry/src/main/java/io/sentry/PropagationContext.java b/sentry/src/main/java/io/sentry/PropagationContext.java index 791cb1d3d36..547b09f3861 100644 --- a/sentry/src/main/java/io/sentry/PropagationContext.java +++ b/sentry/src/main/java/io/sentry/PropagationContext.java @@ -51,6 +51,19 @@ public static PropagationContext fromHeaders( sentryTraceHeader.isSampled()); } + public static @NotNull PropagationContext fromExistingTrace( + final @NotNull String traceId, + final @NotNull String spanId, + final @Nullable Double decisionSampleRate, + final @Nullable Double decisionSampleRand) { + return new PropagationContext( + new SentryId(traceId), + new SpanId(), + new SpanId(spanId), + TracingUtils.ensureBaggage(null, null, decisionSampleRate, decisionSampleRand), + null); + } + private @NotNull SentryId traceId; private @NotNull SpanId spanId; private @Nullable SpanId parentSpanId; diff --git a/sentry/src/main/java/io/sentry/util/TracingUtils.java b/sentry/src/main/java/io/sentry/util/TracingUtils.java index 8673b358a99..bae78476aa2 100644 --- a/sentry/src/main/java/io/sentry/util/TracingUtils.java +++ b/sentry/src/main/java/io/sentry/util/TracingUtils.java @@ -28,6 +28,17 @@ public static void startNewTrace(final @NotNull IScopes scopes) { }); } + public static void setTrace( + final @NotNull IScopes scopes, final @NotNull PropagationContext propagationContext) { + scopes.configureScope( + scope -> { + scope.withPropagationContext( + oldPropagationContext -> { + scope.setPropagationContext(propagationContext); + }); + }); + } + public static @Nullable TracingHeaders traceIfAllowed( final @NotNull IScopes scopes, final @NotNull String requestUrl, From 1a52aa2958cf68cd7cca383bb4840758527daa04 Mon Sep 17 00:00:00 2001 From: Karl Heinz Struggl Date: Wed, 5 Mar 2025 06:23:48 -0800 Subject: [PATCH 015/914] adds default issue types to issue templates (#4234) --- .github/ISSUE_TEMPLATE/bug_report_android.yml | 1 + .github/ISSUE_TEMPLATE/bug_report_java.yml | 1 + .github/ISSUE_TEMPLATE/feature_android.yml | 1 + .github/ISSUE_TEMPLATE/feature_java.yml | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report_android.yml b/.github/ISSUE_TEMPLATE/bug_report_android.yml index 20db87e3631..e3cd0de8d92 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_android.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_android.yml @@ -1,6 +1,7 @@ name: 🐞 Bug Report - Android description: Tell us about something that's not working the way we (probably) intend. labels: ["Platform: Android", "Type: Bug"] +type: Bug body: - type: dropdown id: integration diff --git a/.github/ISSUE_TEMPLATE/bug_report_java.yml b/.github/ISSUE_TEMPLATE/bug_report_java.yml index 18ebe6b6203..3eb8a8ffa3d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_java.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_java.yml @@ -1,6 +1,7 @@ name: 🐞 Bug Report - Java description: Tell us about something that's not working the way we (probably) intend. labels: ["Platform: Java", "Type: Bug"] +type: Bug body: - type: dropdown id: integration diff --git a/.github/ISSUE_TEMPLATE/feature_android.yml b/.github/ISSUE_TEMPLATE/feature_android.yml index 31619ab8c9c..30a6001ed3e 100644 --- a/.github/ISSUE_TEMPLATE/feature_android.yml +++ b/.github/ISSUE_TEMPLATE/feature_android.yml @@ -1,6 +1,7 @@ name: 💡 Feature Request - Android description: Tell us about a problem our SDK could solve but doesn't. labels: ["Platform: Android", "Type: Feature Request"] +type: Feature body: - type: textarea id: problem diff --git a/.github/ISSUE_TEMPLATE/feature_java.yml b/.github/ISSUE_TEMPLATE/feature_java.yml index ed509856762..11772b044f0 100644 --- a/.github/ISSUE_TEMPLATE/feature_java.yml +++ b/.github/ISSUE_TEMPLATE/feature_java.yml @@ -1,6 +1,7 @@ name: 💡 Feature Request - Java description: Tell us about a problem our SDK could solve but doesn't. labels: ["Platform: Java", "Type: Feature Request"] +type: Feature body: - type: textarea id: problem From 44791e15ef40d875fd7473b789e4a37afc39c48b Mon Sep 17 00:00:00 2001 From: Lorenzo Cian Date: Thu, 6 Mar 2025 13:48:20 +0100 Subject: [PATCH 016/914] Use `java.net.URI` for parsing URLs in `UrlUtils` (#4210) * refactor: use `java.net.URI` for parsing in `UrlUtils` * reorganize tests * add tests * tests * changelog * Update sentry/src/main/java/io/sentry/util/UrlUtils.java Co-authored-by: Alexander Dinauer * Update CHANGELOG.md --------- Co-authored-by: Alexander Dinauer --- CHANGELOG.md | 5 + .../main/java/io/sentry/util/UrlUtils.java | 135 ++++----------- .../test/java/io/sentry/util/UrlUtilsTest.kt | 161 +++++++++++++++++- 3 files changed, 192 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7376d03922..1b1508b4b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ - The SDK now automatically propagates the trace-context to the native layer. This allows to connect errors on different layers of the application. ([#4137](https://github.com/getsentry/sentry-java/pull/4137)) +### Behavioural Changes + +- Use `java.net.URI` for parsing URLs in `UrlUtils` ([#4210](https://github.com/getsentry/sentry-java/pull/4210)) + - This could affect grouping for issues with messages containing URLs that fall in known corner cases that were handled incorrectly previously (e.g. email in URL path) + ### Dependencies - Bump Native SDK from v0.7.20 to v0.8.1 ([#4137](https://github.com/getsentry/sentry-java/pull/4137)) diff --git a/sentry/src/main/java/io/sentry/util/UrlUtils.java b/sentry/src/main/java/io/sentry/util/UrlUtils.java index dc36ba678c4..6c70cea0495 100644 --- a/sentry/src/main/java/io/sentry/util/UrlUtils.java +++ b/sentry/src/main/java/io/sentry/util/UrlUtils.java @@ -3,10 +3,7 @@ import io.sentry.ISpan; import io.sentry.SpanDataConvention; import io.sentry.protocol.Request; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.net.URI; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -15,123 +12,55 @@ public final class UrlUtils { public static final @NotNull String SENSITIVE_DATA_SUBSTITUTE = "[Filtered]"; - private static final @NotNull Pattern AUTH_REGEX = Pattern.compile("(.+://)(.*@)(.*)"); public static @Nullable UrlDetails parseNullable(final @Nullable String url) { - if (url == null) { - return null; - } - - return parse(url); + return url == null ? null : parse(url); } public static @NotNull UrlDetails parse(final @NotNull String url) { - if (isAbsoluteUrl(url)) { - return splitAbsoluteUrl(url); - } else { - return splitRelativeUrl(url); - } - } - - private static boolean isAbsoluteUrl(@NotNull String url) { - return url.contains("://"); - } - - private static @NotNull UrlDetails splitRelativeUrl(final @NotNull String url) { - final int queryParamSeparatorIndex = url.indexOf("?"); - final int fragmentSeparatorIndex = url.indexOf("#"); - - final @Nullable String baseUrl = - extractBaseUrl(url, queryParamSeparatorIndex, fragmentSeparatorIndex); - final @Nullable String query = - extractQuery(url, queryParamSeparatorIndex, fragmentSeparatorIndex); - final @Nullable String fragment = extractFragment(url, fragmentSeparatorIndex); + try { + URI uri = new URI(url); + if (uri.isAbsolute() && !isValidAbsoluteUrl(uri)) { + return new UrlDetails(null, null, null); + } - return new UrlDetails(baseUrl, query, fragment); - } + final @NotNull String schemeAndSeparator = + 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 fragment = uri.getRawFragment(); - private static @Nullable String extractBaseUrl( - final @NotNull String url, - final int queryParamSeparatorIndex, - final int fragmentSeparatorIndex) { - if (queryParamSeparatorIndex >= 0) { - return url.substring(0, queryParamSeparatorIndex).trim(); - } else if (fragmentSeparatorIndex >= 0) { - return url.substring(0, fragmentSeparatorIndex).trim(); - } else { - return url; - } - } + final @NotNull String filteredUrl = schemeAndSeparator + filterUserInfo(authority) + path; - private static @Nullable String extractQuery( - final @NotNull String url, - final int queryParamSeparatorIndex, - final int fragmentSeparatorIndex) { - if (queryParamSeparatorIndex > 0) { - if (fragmentSeparatorIndex > 0 && fragmentSeparatorIndex > queryParamSeparatorIndex) { - return url.substring(queryParamSeparatorIndex + 1, fragmentSeparatorIndex).trim(); - } else { - return url.substring(queryParamSeparatorIndex + 1).trim(); - } - } else { - return null; - } - } - - private static @Nullable String extractFragment( - final @NotNull String url, final int fragmentSeparatorIndex) { - if (fragmentSeparatorIndex > 0) { - return url.substring(fragmentSeparatorIndex + 1).trim(); - } else { - return null; + return new UrlDetails(filteredUrl, query, fragment); + } catch (Exception e) { + return new UrlDetails(null, null, null); } } - private static @NotNull UrlDetails splitAbsoluteUrl(final @NotNull String url) { + private static boolean isValidAbsoluteUrl(final @NotNull URI uri) { try { - final @NotNull String filteredUrl = urlWithAuthRemoved(url); - final @NotNull URL urlObj = new URL(url); - final @NotNull String baseUrl = baseUrlOnly(filteredUrl); - if (baseUrl.contains("#")) { - // url considered malformed because it has fragment - return new UrlDetails(null, null, null); - } else { - final @Nullable String query = urlObj.getQuery(); - final @Nullable String fragment = urlObj.getRef(); - return new UrlDetails(baseUrl, query, fragment); - } - } catch (MalformedURLException e) { - return new UrlDetails(null, null, null); + uri.toURL(); + } catch (Exception e) { + return false; } + return true; } - private static @NotNull String urlWithAuthRemoved(final @NotNull String url) { - final @NotNull Matcher userInfoMatcher = AUTH_REGEX.matcher(url); - if (userInfoMatcher.matches() && userInfoMatcher.groupCount() == 3) { - final @NotNull String userInfoString = userInfoMatcher.group(2); - final @NotNull String replacementString = - userInfoString.contains(":") - ? (SENSITIVE_DATA_SUBSTITUTE + ":" + SENSITIVE_DATA_SUBSTITUTE + "@") - : (SENSITIVE_DATA_SUBSTITUTE + "@"); - return userInfoMatcher.group(1) + replacementString + userInfoMatcher.group(3); - } else { + private static @NotNull String filterUserInfo(final @NotNull String url) { + if (!url.contains("@")) { return url; } - } - - private static @NotNull String baseUrlOnly(final @NotNull String url) { - final int queryParamSeparatorIndex = url.indexOf("?"); - - if (queryParamSeparatorIndex >= 0) { - return url.substring(0, queryParamSeparatorIndex).trim(); - } else { - final int fragmentSeparatorIndex = url.indexOf("#"); - if (fragmentSeparatorIndex >= 0) { - return url.substring(0, fragmentSeparatorIndex).trim(); - } else { - return url; - } + if (url.startsWith("@")) { + return SENSITIVE_DATA_SUBSTITUTE + url; } + final @NotNull String userInfo = url.substring(0, url.indexOf('@')); + final @NotNull String filteredUserInfo = + userInfo.contains(":") + ? (SENSITIVE_DATA_SUBSTITUTE + ":" + SENSITIVE_DATA_SUBSTITUTE) + : SENSITIVE_DATA_SUBSTITUTE; + return filteredUserInfo + url.substring(url.indexOf('@')); } public static final class UrlDetails { diff --git a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt index af037b3344a..ea407126544 100644 --- a/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/UrlUtilsTest.kt @@ -142,7 +142,7 @@ class UrlUtilsTest { } @Test - fun `splits url without query or fragment and no authority`() { + fun `splits url without query or fragment and no user info`() { val urlDetails = UrlUtils.parse( "https://sentry.io" ) @@ -161,20 +161,41 @@ class UrlUtilsTest { assertEquals("top", urlDetails.fragment) } + // Fragment is allowed to contain '?' according to RFC 3986 @Test - fun `no details extracted with query after fragment`() { + fun `extracts details with question mark after fragment`() { val urlDetails = UrlUtils.parse( "https://user:password@sentry.io#fragment?q=1&s=2&token=secret" ) + assertEquals("https://[Filtered]:[Filtered]@sentry.io", urlDetails.url) + assertNull(urlDetails.query) + assertEquals("fragment?q=1&s=2&token=secret", urlDetails.fragment) + } + + @Test + fun `extracts details with question mark after fragment without user info`() { + val urlDetails = UrlUtils.parse( + "https://sentry.io#fragment?q=1&s=2&token=secret" + ) + assertEquals("https://sentry.io", urlDetails.url) + assertNull(urlDetails.query) + assertEquals("fragment?q=1&s=2&token=secret", urlDetails.fragment) + } + + @Test + fun `no details extracted from malformed url due to invalid protocol`() { + val urlDetails = UrlUtils.parse( + "htps://user@sentry.io#fragment?q=1&s=2&token=secret" + ) assertNull(urlDetails.url) assertNull(urlDetails.query) assertNull(urlDetails.fragment) } @Test - fun `no details extracted with query after fragment without authority`() { + fun `no details extracted from malformed url due to # symbol in fragment`() { val urlDetails = UrlUtils.parse( - "https://sentry.io#fragment?q=1&s=2&token=secret" + "https://example.com#hello#fragment" ) assertNull(urlDetails.url) assertNull(urlDetails.query) @@ -182,9 +203,137 @@ class UrlUtilsTest { } @Test - fun `no details extracted from malformed url`() { + fun `strips empty user info`() { val urlDetails = UrlUtils.parse( - "htps://user@sentry.io#fragment?q=1&s=2&token=secret" + "https://@sentry.io?query=a#fragment?q=1&s=2&token=secret" + ) + assertEquals("https://[Filtered]@sentry.io", urlDetails.url) + assertEquals("query=a", urlDetails.query) + assertEquals("fragment?q=1&s=2&token=secret", urlDetails.fragment) + } + + @Test + fun `extracts details from relative url with leading @ symbol`() { + val urlDetails = UrlUtils.parse( + "@@sentry.io/pages/10?query=a#fragment?q=1&s=2&token=secret" + ) + assertEquals("@@sentry.io/pages/10", urlDetails.url) + assertEquals("query=a", urlDetails.query) + assertEquals("fragment?q=1&s=2&token=secret", urlDetails.fragment) + } + + @Test + fun `extracts details from relative url with leading question mark`() { + val urlDetails = UrlUtils.parse( + "?query=a#fragment?q=1&s=2&token=secret" + ) + assertEquals("", urlDetails.url) + assertEquals("query=a", urlDetails.query) + assertEquals("fragment?q=1&s=2&token=secret", urlDetails.fragment) + } + + @Test + fun `does not filter email address in path`() { + val urlDetails = UrlUtils.parseNullable( + "https://staging.server.com/api/v4/auth/password/reset/email@example.com" + )!! + assertEquals("https://staging.server.com/api/v4/auth/password/reset/email@example.com", urlDetails.url) + assertNull(urlDetails.query) + assertNull(urlDetails.fragment) + } + + @Test + fun `does not filter email address in path with fragment`() { + val urlDetails = UrlUtils.parseNullable( + "https://staging.server.com/api/v4/auth/password/reset/email@example.com#top" + )!! + assertEquals("https://staging.server.com/api/v4/auth/password/reset/email@example.com", urlDetails.url) + assertNull(urlDetails.query) + assertEquals("top", urlDetails.fragment) + } + + @Test + fun `does not filter email address in path with query and fragment`() { + val urlDetails = UrlUtils.parseNullable( + "https://staging.server.com/api/v4/auth/password/reset/email@example.com?a=b&c=d#top" + )!! + assertEquals("https://staging.server.com/api/v4/auth/password/reset/email@example.com", urlDetails.url) + assertEquals("a=b&c=d", urlDetails.query) + assertEquals("top", urlDetails.fragment) + } + + @Test + fun `does not filter email address in query`() { + val urlDetails = UrlUtils.parseNullable( + "https://staging.server.com/?email=someone@example.com" + )!! + assertEquals("https://staging.server.com/", urlDetails.url) + assertEquals("email=someone@example.com", urlDetails.query) + } + + @Test + fun `does not filter email address in fragment`() { + val urlDetails = UrlUtils.parseNullable( + "https://staging.server.com#email=someone@example.com" + )!! + assertEquals("https://staging.server.com", urlDetails.url) + assertEquals("email=someone@example.com", urlDetails.fragment) + } + + @Test + fun `does not filter email address in fragment with query`() { + val urlDetails = UrlUtils.parseNullable( + "https://staging.server.com?q=a&b=c#email=someone@example.com" + )!! + assertEquals("https://staging.server.com", urlDetails.url) + assertEquals("q=a&b=c", urlDetails.query) + assertEquals("email=someone@example.com", urlDetails.fragment) + } + + @Test + fun `extracts details from relative url with email in path`() { + val urlDetails = UrlUtils.parse( + "/emails/user@sentry.io?query=a&b=c#fragment?q=1&s=2&token=secret" + ) + assertEquals("/emails/user@sentry.io", urlDetails.url) + assertEquals("query=a&b=c", urlDetails.query) + assertEquals("fragment?q=1&s=2&token=secret", urlDetails.fragment) + } + + @Test + fun `extracts details from relative url with email in query`() { + val urlDetails = UrlUtils.parse( + "users/10?email=user@sentry.io&b=c#fragment?q=1&s=2&token=secret" + ) + assertEquals("users/10", urlDetails.url) + assertEquals("email=user@sentry.io&b=c", urlDetails.query) + assertEquals("fragment?q=1&s=2&token=secret", urlDetails.fragment) + } + + @Test + fun `extracts details from relative url with email in fragment`() { + val urlDetails = UrlUtils.parse( + "users/10?email=user@sentry.io&b=c#fragment?q=1&s=2&email=user@sentry.io" + ) + assertEquals("users/10", urlDetails.url) + assertEquals("email=user@sentry.io&b=c", urlDetails.query) + assertEquals("fragment?q=1&s=2&email=user@sentry.io", urlDetails.fragment) + } + + @Test + fun `extracts path from file url`() { + val urlDetails = UrlUtils.parse( + "file:///users/sentry/text.txt" + ) + assertEquals("file:///users/sentry/text.txt", urlDetails.url) + assertNull(urlDetails.query) + assertNull(urlDetails.fragment) + } + + @Test + fun `does not extract details from websockets uri`() { + val urlDetails = UrlUtils.parse( + "wss://example.com/socket" ) assertNull(urlDetails.url) assertNull(urlDetails.query) From 66c895b96e2c4516090daf920c99d72d7834aa40 Mon Sep 17 00:00:00 2001 From: Lorenzo Cian Date: Thu, 6 Mar 2025 14:05:48 +0100 Subject: [PATCH 017/914] Add Reactor and Apollo 4 modules to README.md and .craft.yml (#4225) --- .craft.yml | 2 ++ README.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.craft.yml b/.craft.yml index b4944ec3372..32dd9d5c7dc 100644 --- a/.craft.yml +++ b/.craft.yml @@ -57,3 +57,5 @@ targets: maven:io.sentry:sentry-apollo-3: maven:io.sentry:sentry-android-sqlite: maven:io.sentry:sentry-android-replay: + maven:io.sentry:sentry-apollo-4: + maven:io.sentry:sentry-reactor: diff --git a/README.md b/README.md index 50c281cdfaa..f2fed34a829 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Sentry SDK for Java and Android | sentry-jdbc | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-jdbc/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-jdbc) | | sentry-apollo | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo) | 21 | | sentry-apollo-3 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo-3/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo-3) | 21 | +| sentry-apollo-4 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo-4/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-apollo-4) | 21 | | sentry-kotlin-extensions | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-kotlin-extensions/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-kotlin-extensions) | 21 | | sentry-servlet | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet) | | | sentry-servlet-jakarta | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet-jakarta/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-servlet-jakarta) | | @@ -56,6 +57,7 @@ Sentry SDK for Java and Android | sentry-opentelemetry-agentcustomization | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agentcustomization/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agentcustomization) | | sentry-opentelemetry-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core) | | sentry-okhttp | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp) | +| sentry-reactor | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-reactor) | # Releases From ff09dc4ad9fd2944ab81a7a985f9d1b95e61326c Mon Sep 17 00:00:00 2001 From: Lorenzo Cian Date: Thu, 6 Mar 2025 14:46:22 +0100 Subject: [PATCH 018/914] Report missing integrations (#4229) * Report missing integrations * fix name * spotless --- .../android/core/CurrentActivityIntegration.java | 5 +++++ .../android/core/EnvelopeFileObserverIntegration.java | 3 +++ .../java/io/sentry/openfeign/SentryFeignClient.java | 10 ++++++++++ .../src/main/java/io/sentry/SpotlightIntegration.java | 2 ++ 4 files changed, 20 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java index 0b618636d32..3ce358efbc5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java @@ -1,11 +1,14 @@ package io.sentry.android.core; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import android.app.Activity; import android.app.Application; import android.os.Bundle; import androidx.annotation.NonNull; import io.sentry.IScopes; import io.sentry.Integration; +import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.util.Objects; import java.io.Closeable; @@ -27,6 +30,8 @@ public CurrentActivityIntegration(final @NotNull Application application) { @Override public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { application.registerActivityLifecycleCallbacks(this); + options.getLogger().log(SentryLevel.DEBUG, "CurrentActivityIntegration installed."); + addIntegrationToSdkVersion("CurrentActivity"); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java index a921f794588..482d90c6e6c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java @@ -1,5 +1,7 @@ package io.sentry.android.core; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -80,6 +82,7 @@ private void startOutboxSender( try { observer.startWatching(); options.getLogger().log(SentryLevel.DEBUG, "EnvelopeFileObserverIntegration installed."); + addIntegrationToSdkVersion("EnvelopeFileObserver"); } catch (Throwable e) { // it could throw eg NoSuchFileException or NullPointerException options 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 935c4229ab1..37a52af1646 100644 --- a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java +++ b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java @@ -2,15 +2,18 @@ import static io.sentry.TypeCheckHint.OPEN_FEIGN_REQUEST; import static io.sentry.TypeCheckHint.OPEN_FEIGN_RESPONSE; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import feign.Client; import feign.Request; import feign.Response; import io.sentry.BaggageHeader; import io.sentry.Breadcrumb; +import io.sentry.BuildConfig; import io.sentry.Hint; import io.sentry.IScopes; import io.sentry.ISpan; +import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SpanDataConvention; import io.sentry.SpanOptions; import io.sentry.SpanStatus; @@ -42,6 +45,13 @@ public SentryFeignClient( this.delegate = Objects.requireNonNull(delegate, "delegate is required"); this.scopes = Objects.requireNonNull(scopes, "scopes are required"); this.beforeSpan = beforeSpan; + addPackageAndIntegrationInfo(); + } + + private void addPackageAndIntegrationInfo() { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-openfeign", BuildConfig.VERSION_NAME); + addIntegrationToSdkVersion("OpenFeign"); } @Override diff --git a/sentry/src/main/java/io/sentry/SpotlightIntegration.java b/sentry/src/main/java/io/sentry/SpotlightIntegration.java index 0b69ae79be7..910259ad131 100644 --- a/sentry/src/main/java/io/sentry/SpotlightIntegration.java +++ b/sentry/src/main/java/io/sentry/SpotlightIntegration.java @@ -3,6 +3,7 @@ import static io.sentry.SentryLevel.DEBUG; import static io.sentry.SentryLevel.ERROR; import static io.sentry.SentryLevel.WARNING; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import io.sentry.util.Platform; import java.io.Closeable; @@ -34,6 +35,7 @@ public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { executorService = new SentryExecutorService(); options.setBeforeEnvelopeCallback(this); logger.log(DEBUG, "SpotlightIntegration enabled."); + addIntegrationToSdkVersion("Spotlight"); } else { logger.log( DEBUG, From 7074d0b5b54d5478d6d12b6a744af3e9736b014f Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 7 Mar 2025 11:24:45 +0100 Subject: [PATCH 019/914] Add support for setting in-app-includes/in-app-excludes via AndroidManifest.xml (#4240) * Add support for setting in-app-includes/in-app-excludes via AndroidManifest.xml * Update Changelog --- CHANGELOG.md | 6 +++ .../android/core/ManifestMetadataReader.java | 19 ++++++- .../core/ManifestMetadataReaderTest.kt | 50 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1508b4b7d..440881b5940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Add support for setting in-app-includes/in-app-excludes via AndroidManifest.xml ([#4240](https://github.com/getsentry/sentry-java/pull/4240)) + ### Features - The SDK now automatically propagates the trace-context to the native layer. This allows to connect errors on different layers of the application. ([#4137](https://github.com/getsentry/sentry-java/pull/4137)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 86d9d6aa292..71c6894045e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -107,6 +107,10 @@ final class ManifestMetadataReader { static final String IGNORED_ERRORS = "io.sentry.ignored-errors"; + static final String IN_APP_INCLUDES = "io.sentry.in-app-includes"; + + static final String IN_APP_EXCLUDES = "io.sentry.in-app-excludes"; + static final String ENABLE_AUTO_TRACE_ID_GENERATION = "io.sentry.traces.enable-auto-id-generation"; @@ -414,8 +418,21 @@ static void applyMetadata( .setMaskAllImages(readBool(metadata, logger, REPLAYS_MASK_ALL_IMAGES, true)); options.setIgnoredErrors(readList(metadata, logger, IGNORED_ERRORS)); - } + final @Nullable List includes = readList(metadata, logger, IN_APP_INCLUDES); + if (includes != null && !includes.isEmpty()) { + for (final @NotNull String include : includes) { + options.addInAppInclude(include); + } + } + + final @Nullable List excludes = readList(metadata, logger, IN_APP_EXCLUDES); + if (excludes != null && !excludes.isEmpty()) { + for (final @NotNull String exclude : excludes) { + options.addInAppExclude(exclude); + } + } + } options .getLogger() .log(SentryLevel.INFO, "Retrieving configuration from AndroidManifest.xml"); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 16f51948d40..263c9c5950c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1447,4 +1447,54 @@ class ManifestMetadataReaderTest { // Assert assertEquals(listOf(FilterString("Some error"), FilterString("Another .*")), fixture.options.ignoredErrors) } + + @Test + fun `applyMetadata reads inAppIncludes to options and sets the value if found`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.IN_APP_INCLUDES to "com.example.package1,com.example.package2") + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(listOf("com.example.package1", "com.example.package2"), fixture.options.inAppIncludes) + } + + @Test + fun `applyMetadata reads inAppIncludes to options and keeps empty if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.inAppIncludes.isEmpty()) + } + + @Test + fun `applyMetadata reads inAppExcludes to options and sets the value if found`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.IN_APP_EXCLUDES to "com.example.excluded1,com.example.excluded2") + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(listOf("com.example.excluded1", "com.example.excluded2"), fixture.options.inAppExcludes) + } + + @Test + fun `applyMetadata reads inAppExcludes to options and keeps empty if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.inAppExcludes.isEmpty()) + } } From 033bc88e4bea3331f0747375f7a21ac78be75126 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Mar 2025 08:20:44 +0100 Subject: [PATCH 020/914] Capture OpenTelemetry span events (#3564) * capture otel events * Set trace for captured error; set timestamp; refactor * changelog * fix external option name * remove duplicate dependency entry * ignore buildSrc/.kotlin --- .gitignore | 1 + CHANGELOG.md | 5 +++ .../OtelSentrySpanProcessor.java | 44 +++++++++++++++++++ .../jakarta/SentryAutoConfigurationTest.kt | 2 + .../boot/SentryAutoConfigurationTest.kt | 2 + sentry/api/sentry.api | 4 ++ .../main/java/io/sentry/ExternalOptions.java | 14 ++++++ .../main/java/io/sentry/SentryOptions.java | 15 ++++++- .../java/io/sentry/ExternalOptionsTest.kt | 14 ++++++ 9 files changed, 100 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 92ea301ff84..8391451622f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ distributions/ sentry-spring-boot-starter-jakarta/src/main/resources/META-INF/spring.factories sentry-samples/sentry-samples-spring-boot-jakarta/spy.log spy.log +buildSrc/.kotlin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 440881b5940..85b3736edaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ ### Features - The SDK now automatically propagates the trace-context to the native layer. This allows to connect errors on different layers of the application. ([#4137](https://github.com/getsentry/sentry-java/pull/4137)) +- Capture OpenTelemetry span events ([#3564](https://github.com/getsentry/sentry-java/pull/3564)) + - OpenTelemetry spans may have exceptions attached to them (`openTelemetrySpan.recordException`). We can now send those to Sentry as errors. + - Set `capture-open-telemetry-events=true` in `sentry.properties` to enable it + - Set `sentry.capture-open-telemetry-events=true` in Springs `application.properties` to enable it + - Set `sentry.captureOpenTelemetryEvents: true` in Springs `application.yml` to enable it ### Behavioural Changes diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java index 6469ea92099..5aa3ab76332 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java @@ -8,18 +8,25 @@ import io.opentelemetry.sdk.trace.ReadWriteSpan; import io.opentelemetry.sdk.trace.ReadableSpan; import io.opentelemetry.sdk.trace.SpanProcessor; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.ExceptionEventData; import io.sentry.Baggage; +import io.sentry.DateUtils; import io.sentry.IScopes; import io.sentry.PropagationContext; import io.sentry.ScopesAdapter; import io.sentry.Sentry; import io.sentry.SentryDate; +import io.sentry.SentryEvent; import io.sentry.SentryLevel; import io.sentry.SentryLongDate; import io.sentry.SentryTraceHeader; import io.sentry.SpanId; import io.sentry.TracesSamplingDecision; +import io.sentry.exception.ExceptionMechanismException; +import io.sentry.protocol.Mechanism; import io.sentry.protocol.SentryId; +import java.util.List; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -143,9 +150,46 @@ public void onEnd(final @NotNull ReadableSpan spanBeingEnded) { final @NotNull SentryDate finishDate = new SentryLongDate(spanBeingEnded.toSpanData().getEndEpochNanos()); sentrySpan.updateEndDate(finishDate); + + maybeCaptureSpanEventsAsExceptions(spanBeingEnded, sentrySpan); } } + private void maybeCaptureSpanEventsAsExceptions( + final @NotNull ReadableSpan spanBeingEnded, final @NotNull IOtelSpanWrapper sentrySpan) { + final @NotNull IScopes spanScopes = sentrySpan.getScopes(); + if (spanScopes.getOptions().isCaptureOpenTelemetryEvents()) { + final @NotNull List events = spanBeingEnded.toSpanData().getEvents(); + for (EventData event : events) { + if (event instanceof ExceptionEventData) { + final @NotNull ExceptionEventData exceptionEvent = (ExceptionEventData) event; + captureException(spanScopes, exceptionEvent, sentrySpan); + } + } + } + } + + private void captureException( + final @NotNull IScopes scopes, + final @NotNull ExceptionEventData exceptionEvent, + final @NotNull IOtelSpanWrapper sentrySpan) { + final @NotNull Throwable exception = exceptionEvent.getException(); + final Mechanism mechanism = new Mechanism(); + mechanism.setType("OpenTelemetrySpanEvent"); + mechanism.setHandled(true); + // This is potentially the wrong Thread as it's the current thread meaning the thread where + // the span is being ended on. This may not match the thread where the exception occurred. + final Throwable mechanismException = + new ExceptionMechanismException(mechanism, exception, Thread.currentThread()); + + final SentryEvent event = new SentryEvent(mechanismException); + event.setTimestamp(DateUtils.nanosToDate(exceptionEvent.getEpochNanos())); + event.setLevel(SentryLevel.ERROR); + event.getContexts().setTrace(sentrySpan.getSpanContext()); + + scopes.captureEvent(event); + } + @Override public boolean isEndRequired() { return true; diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index e5b4a901662..bcc56f3bd99 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -182,6 +182,7 @@ class SentryAutoConfigurationTest { "sentry.spotlight-connection-url=http://local.sentry.io:1234", "sentry.force-init=true", "sentry.global-hub-mode=true", + "sentry.capture-open-telemetry-events=true", "sentry.cron.default-checkin-margin=10", "sentry.cron.default-max-runtime=30", "sentry.cron.default-timezone=America/New_York", @@ -222,6 +223,7 @@ class SentryAutoConfigurationTest { assertThat(options.isEnableBackpressureHandling).isEqualTo(false) assertThat(options.isForceInit).isEqualTo(true) assertThat(options.isGlobalHubMode).isEqualTo(true) + assertThat(options.isCaptureOpenTelemetryEvents).isEqualTo(true) assertThat(options.isEnableSpotlight).isEqualTo(true) assertThat(options.spotlightConnectionUrl).isEqualTo("http://local.sentry.io:1234") assertThat(options.cron).isNotNull diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index b3c1effa419..ddaa51a7646 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -181,6 +181,7 @@ class SentryAutoConfigurationTest { "sentry.spotlight-connection-url=http://local.sentry.io:1234", "sentry.force-init=true", "sentry.global-hub-mode=true", + "sentry.capture-open-telemetry-events=true", "sentry.cron.default-checkin-margin=10", "sentry.cron.default-max-runtime=30", "sentry.cron.default-timezone=America/New_York", @@ -221,6 +222,7 @@ class SentryAutoConfigurationTest { assertThat(options.isEnableBackpressureHandling).isEqualTo(false) assertThat(options.isForceInit).isEqualTo(true) assertThat(options.isGlobalHubMode).isEqualTo(true) + assertThat(options.isCaptureOpenTelemetryEvents).isEqualTo(true) assertThat(options.isEnableSpotlight).isEqualTo(true) assertThat(options.spotlightConnectionUrl).isEqualTo("http://local.sentry.io:1234") assertThat(options.cron).isNotNull diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 1c4883e078e..fb29473c4ac 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -479,6 +479,7 @@ public final class io/sentry/ExternalOptions { public fun getTags ()Ljava/util/Map; public fun getTracePropagationTargets ()Ljava/util/List; public fun getTracesSampleRate ()Ljava/lang/Double; + public fun isCaptureOpenTelemetryEvents ()Ljava/lang/Boolean; public fun isEnableBackpressureHandling ()Ljava/lang/Boolean; public fun isEnablePrettySerializationOutput ()Ljava/lang/Boolean; public fun isEnableSpotlight ()Ljava/lang/Boolean; @@ -487,6 +488,7 @@ public final class io/sentry/ExternalOptions { public fun isGlobalHubMode ()Ljava/lang/Boolean; public fun isSendDefaultPii ()Ljava/lang/Boolean; public fun isSendModules ()Ljava/lang/Boolean; + public fun setCaptureOpenTelemetryEvents (Ljava/lang/Boolean;)V public fun setCron (Lio/sentry/SentryOptions$Cron;)V public fun setDebug (Ljava/lang/Boolean;)V public fun setDist (Ljava/lang/String;)V @@ -2930,6 +2932,7 @@ public class io/sentry/SentryOptions { public fun isAttachServerName ()Z public fun isAttachStacktrace ()Z public fun isAttachThreads ()Z + public fun isCaptureOpenTelemetryEvents ()Z public fun isDebug ()Z public fun isEnableAppStartProfiling ()Z public fun isEnableAutoSessionTracking ()Z @@ -2967,6 +2970,7 @@ public class io/sentry/SentryOptions { public fun setBeforeSendReplay (Lio/sentry/SentryOptions$BeforeSendReplayCallback;)V public fun setBeforeSendTransaction (Lio/sentry/SentryOptions$BeforeSendTransactionCallback;)V public fun setCacheDirPath (Ljava/lang/String;)V + public fun setCaptureOpenTelemetryEvents (Z)V public fun setConnectionStatusProvider (Lio/sentry/IConnectionStatusProvider;)V public fun setConnectionTimeoutMillis (I)V public fun setCron (Lio/sentry/SentryOptions$Cron;)V diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 62954a0e9b1..15907342664 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -53,6 +53,7 @@ public final class ExternalOptions { private @Nullable Boolean enableBackpressureHandling; private @Nullable Boolean globalHubMode; private @Nullable Boolean forceInit; + private @Nullable Boolean captureOpenTelemetryEvents; private @Nullable SentryOptions.Cron cron; @@ -146,6 +147,9 @@ public final class ExternalOptions { options.setGlobalHubMode(propertiesProvider.getBooleanProperty("global-hub-mode")); + options.setCaptureOpenTelemetryEvents( + propertiesProvider.getBooleanProperty("capture-open-telemetry-events")); + for (final String ignoredExceptionType : propertiesProvider.getList("ignored-exceptions-for-type")) { try { @@ -504,4 +508,14 @@ public void setEnableSpotlight(final @Nullable Boolean enableSpotlight) { public void setSpotlightConnectionUrl(final @Nullable String spotlightConnectionUrl) { this.spotlightConnectionUrl = spotlightConnectionUrl; } + + @ApiStatus.Experimental + public void setCaptureOpenTelemetryEvents(final @Nullable Boolean captureOpenTelemetryEvents) { + this.captureOpenTelemetryEvents = captureOpenTelemetryEvents; + } + + @ApiStatus.Experimental + public @Nullable Boolean isCaptureOpenTelemetryEvents() { + return captureOpenTelemetryEvents; + } } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 384b097ee65..b2a785faf76 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -530,6 +530,7 @@ public class SentryOptions { private @NotNull SentryReplayOptions sessionReplay; + @ApiStatus.Experimental private boolean captureOpenTelemetryEvents = false; /** * Adds an event processor * @@ -2634,6 +2635,16 @@ public void setSessionReplay(final @NotNull SentryReplayOptions sessionReplayOpt this.sessionReplay = sessionReplayOptions; } + @ApiStatus.Experimental + public void setCaptureOpenTelemetryEvents(final boolean captureOpenTelemetryEvents) { + this.captureOpenTelemetryEvents = captureOpenTelemetryEvents; + } + + @ApiStatus.Experimental + public boolean isCaptureOpenTelemetryEvents() { + return captureOpenTelemetryEvents; + } + /** * Load the lazy fields. Useful to load in the background, so that results are already cached. DO * NOT CALL THIS METHOD ON THE MAIN THREAD. @@ -2927,7 +2938,9 @@ public void merge(final @NotNull ExternalOptions options) { if (options.isSendDefaultPii() != null) { setSendDefaultPii(options.isSendDefaultPii()); } - + if (options.isCaptureOpenTelemetryEvents() != null) { + setCaptureOpenTelemetryEvents(options.isCaptureOpenTelemetryEvents()); + } if (options.isEnableSpotlight() != null) { setEnableSpotlight(options.isEnableSpotlight()); } diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 5bb0e5bae0c..dbf0001d1c1 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -361,6 +361,20 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with captureOpenTelemetryEvents set to false`() { + withPropertiesFile("capture-open-telemetry-events=false") { options -> + assertTrue(options.isCaptureOpenTelemetryEvents == false) + } + } + + @Test + fun `creates options with captureOpenTelemetryEvents set to true`() { + withPropertiesFile("capture-open-telemetry-events=true") { options -> + assertTrue(options.isCaptureOpenTelemetryEvents == true) + } + } + private fun withPropertiesFile(textLines: List = emptyList(), logger: ILogger = mock(), fn: (ExternalOptions) -> Unit) { // create a sentry.properties file in temporary folder val temporaryFolder = TemporaryFolder() From 762ee2d793c77fc999a10816624acf6cbcdd4891 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Mar 2025 09:53:06 +0100 Subject: [PATCH 021/914] Also use port when checking if a request is made to Sentry DSN (#4231) * Also use port when checking if a request is made to Sentry DSN * changelog --- CHANGELOG.md | 12 +- .../OpenTelemetryAttributesExtractor.java | 7 + .../OtelInternalSpanDetectionUtil.java | 18 +- .../OpenTelemetryAttributesExtractorTest.kt | 14 + .../OtelInternalSpanDetectionUtilTest.kt | 251 ++++++++++++++++++ sentry/src/main/java/io/sentry/DsnUtil.java | 9 +- sentry/src/test/java/io/sentry/DsnUtilTest.kt | 10 + 7 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b3736edaf..78971fa10f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Behavioural Changes + +- Use `java.net.URI` for parsing URLs in `UrlUtils` ([#4210](https://github.com/getsentry/sentry-java/pull/4210)) + - This could affect grouping for issues with messages containing URLs that fall in known corner cases that were handled incorrectly previously (e.g. email in URL path) + ### Fixes - Add support for setting in-app-includes/in-app-excludes via AndroidManifest.xml ([#4240](https://github.com/getsentry/sentry-java/pull/4240)) @@ -15,10 +20,11 @@ - Set `sentry.capture-open-telemetry-events=true` in Springs `application.properties` to enable it - Set `sentry.captureOpenTelemetryEvents: true` in Springs `application.yml` to enable it -### Behavioural Changes +### Internal -- Use `java.net.URI` for parsing URLs in `UrlUtils` ([#4210](https://github.com/getsentry/sentry-java/pull/4210)) - - This could affect grouping for issues with messages containing URLs that fall in known corner cases that were handled incorrectly previously (e.g. email in URL path) +- Also use port when checking if a request is made to Sentry DSN ([#4231](https://github.com/getsentry/sentry-java/pull/4231)) + - For our OpenTelemetry integration we check if a span is for a request to Sentry + - We now also consider the port when performing this check ### Dependencies 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..7d4db373df8 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 @@ -105,6 +105,7 @@ private static Map collectHeaders( return headers; } + @SuppressWarnings("deprecation") public @Nullable String extractUrl( final @NotNull Attributes attributes, final @NotNull SentryOptions options) { final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); @@ -112,6 +113,12 @@ private static Map collectHeaders( return urlFull; } + final @Nullable String deprecatedUrl = + attributes.get(io.opentelemetry.semconv.SemanticAttributes.HTTP_URL); + if (deprecatedUrl != null) { + return deprecatedUrl; + } + final String urlString = buildUrlString(attributes, options); if (!urlString.isEmpty()) { return urlString; diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelInternalSpanDetectionUtil.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelInternalSpanDetectionUtil.java index e4bd5a7e7c8..b1bbdede526 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelInternalSpanDetectionUtil.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelInternalSpanDetectionUtil.java @@ -2,7 +2,6 @@ import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.SpanKind; -import io.opentelemetry.semconv.UrlAttributes; import io.sentry.DsnUtil; import io.sentry.IScopes; import java.util.Arrays; @@ -17,6 +16,8 @@ public final class OtelInternalSpanDetectionUtil { private static final @NotNull List spanKindsConsideredForSentryRequests = Arrays.asList(SpanKind.CLIENT, SpanKind.INTERNAL); + private static final @NotNull OpenTelemetryAttributesExtractor attributesExtractor = + new OpenTelemetryAttributesExtractor(); @SuppressWarnings("deprecation") public static boolean isSentryRequest( @@ -27,14 +28,8 @@ public static boolean isSentryRequest( return false; } - final @Nullable String httpUrl = - attributes.get(io.opentelemetry.semconv.SemanticAttributes.HTTP_URL); - if (DsnUtil.urlContainsDsnHost(scopes.getOptions(), httpUrl)) { - return true; - } - - final @Nullable String fullUrl = attributes.get(UrlAttributes.URL_FULL); - if (DsnUtil.urlContainsDsnHost(scopes.getOptions(), fullUrl)) { + String url = attributesExtractor.extractUrl(attributes, scopes.getOptions()); + if (DsnUtil.urlContainsDsnHost(scopes.getOptions(), url)) { return true; } @@ -43,10 +38,7 @@ public static boolean isSentryRequest( final @NotNull String spotlightUrl = optionsSpotlightUrl != null ? optionsSpotlightUrl : "http://localhost:8969/stream"; - if (containsSpotlightUrl(fullUrl, spotlightUrl)) { - return true; - } - if (containsSpotlightUrl(httpUrl, spotlightUrl)) { + if (containsSpotlightUrl(url, spotlightUrl)) { return true; } } 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 1227509e0d0..e235ba8ca0b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -4,6 +4,7 @@ import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.sdk.internal.AttributesMap import io.opentelemetry.sdk.trace.data.SpanData import io.opentelemetry.semconv.HttpAttributes +import io.opentelemetry.semconv.SemanticAttributes import io.opentelemetry.semconv.ServerAttributes import io.opentelemetry.semconv.UrlAttributes import io.sentry.Scope @@ -202,6 +203,19 @@ class OpenTelemetryAttributesExtractorTest { assertEquals("https://sentry.io/some/path", url) } + @Test + fun `returns deprecated URL if present`() { + givenAttributes( + mapOf( + SemanticAttributes.HTTP_URL to "https://sentry.io/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io/some/path", url) + } + @Test fun `returns reconstructed URL if attributes present`() { givenAttributes( diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt new file mode 100644 index 00000000000..6cc62dd1a0e --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt @@ -0,0 +1,251 @@ +package io.sentry.opentelemetry + +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.sdk.internal.AttributesMap +import io.opentelemetry.semconv.HttpAttributes +import io.opentelemetry.semconv.SemanticAttributes +import io.opentelemetry.semconv.ServerAttributes +import io.opentelemetry.semconv.UrlAttributes +import io.sentry.IScopes +import io.sentry.SentryOptions +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OtelInternalSpanDetectionUtilTest { + + private class Fixture { + val scopes = mock() + val attributes = AttributesMap.create(100, 100) + val options = SentryOptions.empty() + var spanKind: SpanKind = SpanKind.INTERNAL + + init { + whenever(scopes.options).thenReturn(options) + } + } + + private val fixture = Fixture() + + @Test + fun `detects split url as internal (span kind client)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_SCHEME to "https", + UrlAttributes.URL_PATH to "/path/to/123", + UrlAttributes.URL_QUERY to "q=123456&b=X", + ServerAttributes.SERVER_ADDRESS to "io.sentry", + ServerAttributes.SERVER_PORT to 8081L + ) + ) + + thenRequestIsConsideredInternal() + } + + @Test + fun `detects full url as internal (span kind client)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "https://io.sentry:8081" + ) + ) + + thenRequestIsConsideredInternal() + } + + @Test + fun `detects deprecated url as internal (span kind client)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + SemanticAttributes.HTTP_URL to "https://io.sentry:8081" + ) + ) + + thenRequestIsConsideredInternal() + } + + @Test + fun `detects split url as internal (span kind internal)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.INTERNAL) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_SCHEME to "https", + UrlAttributes.URL_PATH to "/path/to/123", + UrlAttributes.URL_QUERY to "q=123456&b=X", + ServerAttributes.SERVER_ADDRESS to "io.sentry", + ServerAttributes.SERVER_PORT to 8081L + ) + ) + + thenRequestIsConsideredInternal() + } + + @Test + fun `detects full url as internal (span kind internal)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.INTERNAL) + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "https://io.sentry:8081" + ) + ) + + thenRequestIsConsideredInternal() + } + + @Test + fun `detects deprecated url as internal (span kind internal)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.INTERNAL) + givenAttributes( + mapOf( + SemanticAttributes.HTTP_URL to "https://io.sentry:8081" + ) + ) + + thenRequestIsConsideredInternal() + } + + @Test + fun `does not detect full url as internal (span kind server)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.SERVER) + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "https://io.sentry:8081" + ) + ) + + thenRequestIsNotConsideredInternal() + } + + @Test + fun `does not detect full url as internal (span kind producer)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.PRODUCER) + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "https://io.sentry:8081" + ) + ) + + thenRequestIsNotConsideredInternal() + } + + @Test + fun `does not detect full url as internal (span kind consumer)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.CONSUMER) + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "https://io.sentry:8081" + ) + ) + + thenRequestIsNotConsideredInternal() + } + + @Test + fun `detects full spotlight url as internal`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpotlightEnabled(true) + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "http://localhost:8969/stream" + ) + ) + + thenRequestIsConsideredInternal() + } + + @Test + fun `detects full spotlight url as internal with custom spotlight url`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpotlightEnabled(true) + givenSpotlightUrl("http://localhost:8090/stream") + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "http://localhost:8090/stream" + ) + ) + + thenRequestIsConsideredInternal() + } + + @Test + fun `does not detect mismatching full spotlight url as internal`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpotlightEnabled(true) + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "http://localhost:8080/stream" + ) + ) + + thenRequestIsNotConsideredInternal() + } + + @Test + fun `does not detect mismatching full customized spotlight url as internal`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpotlightEnabled(true) + givenSpotlightUrl("http://localhost:8090/stream") + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "http://localhost:8091/stream" + ) + ) + + thenRequestIsNotConsideredInternal() + } + + private fun givenAttributes(map: Map, Any>) { + map.forEach { k, v -> + fixture.attributes.put(k, v) + } + } + + private fun givenDsn(dsn: String) { + fixture.options.dsn = dsn + } + + private fun givenSpotlightEnabled(enabled: Boolean) { + fixture.options.isEnableSpotlight = enabled + } + + private fun givenSpotlightUrl(url: String) { + fixture.options.spotlightConnectionUrl = url + } + + private fun givenSpanKind(spanKind: SpanKind) { + fixture.spanKind = spanKind + } + + private fun thenRequestIsConsideredInternal() { + assertTrue(checkIfInternal()) + } + + private fun thenRequestIsNotConsideredInternal() { + assertFalse(checkIfInternal()) + } + + private fun checkIfInternal(): Boolean { + return OtelInternalSpanDetectionUtil.isSentryRequest(fixture.scopes, fixture.spanKind, fixture.attributes) + } +} diff --git a/sentry/src/main/java/io/sentry/DsnUtil.java b/sentry/src/main/java/io/sentry/DsnUtil.java index b6902ad2741..f31d1c286af 100644 --- a/sentry/src/main/java/io/sentry/DsnUtil.java +++ b/sentry/src/main/java/io/sentry/DsnUtil.java @@ -31,6 +31,13 @@ public static boolean urlContainsDsnHost(@Nullable SentryOptions options, @Nulla return false; } - return url.toLowerCase(Locale.ROOT).contains(dsnHost.toLowerCase(Locale.ROOT)); + final @NotNull String lowerCaseHost = dsnHost.toLowerCase(Locale.ROOT); + final int dsnPort = sentryUri.getPort(); + + if (dsnPort > 0) { + return url.toLowerCase(Locale.ROOT).contains(lowerCaseHost + ":" + dsnPort); + } else { + return url.toLowerCase(Locale.ROOT).contains(lowerCaseHost); + } } } diff --git a/sentry/src/test/java/io/sentry/DsnUtilTest.kt b/sentry/src/test/java/io/sentry/DsnUtilTest.kt index aa0f1c8e4b5..f231db98bb9 100644 --- a/sentry/src/test/java/io/sentry/DsnUtilTest.kt +++ b/sentry/src/test/java/io/sentry/DsnUtilTest.kt @@ -40,6 +40,16 @@ class DsnUtilTest { assertFalse(DsnUtil.urlContainsDsnHost(optionsWithDsn(DSN), null)) } + @Test + fun `returns false for same host but different port`() { + assertFalse(DsnUtil.urlContainsDsnHost(optionsWithDsn("http://publicKey:secretKey@localhost:8080/path/id?sample.rate=0.1"), "localhost:8081")) + } + + @Test + fun `returns true for same host and port`() { + assertTrue(DsnUtil.urlContainsDsnHost(optionsWithDsn("http://publicKey:secretKey@localhost:8080/path/id?sample.rate=0.1"), "localhost:8080")) + } + private fun optionsWithDsn(dsn: String?): SentryOptions { return SentryOptions().also { it.dsn = dsn From 3bff837834f82507483be7a020fbfd629b27a308 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 11 Mar 2025 10:06:25 +0100 Subject: [PATCH 022/914] Propagate modifications of OkHttp requests to the affected spans / breadcrumbs (#4238) * Update okhttp span/breadcrumbs in case interceptors change the request * Update Changelog * Address PR feedback --- CHANGELOG.md | 2 + .../io/sentry/okhttp/SentryOkHttpEvent.kt | 49 +++++++++++++++---- .../sentry/okhttp/SentryOkHttpInterceptor.kt | 5 ++ .../io/sentry/okhttp/SentryOkHttpEventTest.kt | 30 ++++++++++++ .../okhttp/SentryOkHttpInterceptorTest.kt | 39 ++++++++++++++- 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78971fa10f9..d4574a0fceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixes - Add support for setting in-app-includes/in-app-excludes via AndroidManifest.xml ([#4240](https://github.com/getsentry/sentry-java/pull/4240)) +- Modifications to OkHttp requests are now properly propagated to the affected span / breadcrumbs ([#4238](https://github.com/getsentry/sentry-java/pull/4238)) + - Please ensure the SentryOkHttpInterceptor is added last to your OkHttpClient, as otherwise changes to the `Request` by subsequent interceptors won't be considered ### Features 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 5bc488d0743..7b77ac0f9f4 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt @@ -28,28 +28,59 @@ internal class SentryOkHttpEvent(private val scopes: IScopes, private val reques private var response: Response? = null private var clientErrorResponse: Response? = null private val isEventFinished = AtomicBoolean(false) - private val url: String - private val method: String + private var url: String + private var method: String init { val urlDetails = UrlUtils.parse(request.url.toString()) url = urlDetails.urlOrFallback - val host: String = request.url.host - val encodedPath: String = request.url.encodedPath method = request.method // We start the call span that will contain all the others val parentSpan = if (Platform.isAndroid()) scopes.transaction else scopes.span - callSpan = parentSpan?.startChild("http.client", "$method $url") + callSpan = parentSpan?.startChild("http.client") callSpan?.spanContext?.origin = TRACE_ORIGIN + + breadcrumb = Breadcrumb().apply { + type = "http" + category = "http" + // needs this as unix timestamp for rrweb + setData( + SpanDataConvention.HTTP_START_TIMESTAMP, + CurrentDateProvider.getInstance().currentTimeMillis + ) + } + + setRequest(request) + } + + /** + * Sets the request. + * This function may be called multiple times in case the request changes e.g. due to interceptors. + */ + fun setRequest(request: Request) { + val urlDetails = UrlUtils.parse(request.url.toString()) + url = urlDetails.urlOrFallback + + val host: String = request.url.host + val encodedPath: String = request.url.encodedPath + method = request.method + + callSpan?.description = "$method $url" urlDetails.applyToSpan(callSpan) - // We setup a breadcrumb with all meaningful data - breadcrumb = Breadcrumb.http(url, method) breadcrumb.setData("host", host) breadcrumb.setData("path", encodedPath) - // needs this as unix timestamp for rrweb - breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, CurrentDateProvider.getInstance().currentTimeMillis) + if (urlDetails.url != null) { + breadcrumb.setData("url", urlDetails.url!!) + } + breadcrumb.setData("method", method.uppercase()) + if (urlDetails.query != null) { + breadcrumb.setData("http.query", urlDetails.query!!) + } + if (urlDetails.fragment != null) { + breadcrumb.setData("http.fragment", urlDetails.fragment!!) + } // We add the same data to the call span callSpan?.setData("url", url) 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 370b3ccb6bc..1f22d1c541b 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -81,6 +81,7 @@ public open class SentryOkHttpInterceptor( val parentSpan = if (Platform.isAndroid()) scopes.transaction else scopes.span span = parentSpan?.startChild("http.client", "$method $url") } + val startTimestamp = CurrentDateProvider.getInstance().currentTimeMillis span?.spanContext?.origin = TRACE_ORIGIN @@ -141,6 +142,10 @@ public open class SentryOkHttpInterceptor( } throw e } finally { + // interceptors may change the request details, so let's update it here + // this only works correctly if SentryOkHttpInterceptor is the last one in the chain + okHttpEvent?.setRequest(request) + finishSpan(span, request, response, isFromEventListener, okHttpEvent) // The SentryOkHttpEventListener will send the breadcrumb itself if used for this call diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt index 33f9b04d85f..4cda8c75a3c 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt @@ -17,6 +17,7 @@ import io.sentry.exception.SentryHttpClientException import io.sentry.test.getProperty import okhttp3.Protocol import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.mockwebserver.MockWebServer import org.mockito.kotlin.any @@ -234,6 +235,35 @@ class SentryOkHttpEventTest { ) } + @Test + fun `setRequest updates both breadcrumb and span data`() { + val sut = fixture.getSut() + + sut.setRequest( + Request.Builder() + .post("".toRequestBody()) + .url("https://foo.bar/updated") + .build() + ) + sut.finish() + + verify(fixture.scopes).addBreadcrumb( + check { + assertEquals("https://foo.bar/updated", it.data["url"]) + assertEquals("foo.bar", it.data["host"]) + assertEquals("/updated", it.data["path"]) + assertEquals("POST", it.data["method"]) + }, + any() + ) + + assertNotNull(sut.callSpan) + assertEquals("/updated", sut.callSpan.getData("path")) + assertEquals("POST", sut.callSpan.getData("http.request.method")) + assertEquals("foo.bar", sut.callSpan.getData("host")) + assertEquals("https://foo.bar/updated", sut.callSpan.getData("url")) + } + @Test fun `when finish multiple times, only one breadcrumb is captured`() { val sut = fixture.getSut() 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 f18b9673337..e0bc8a6837c 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -21,12 +21,14 @@ import io.sentry.TransactionContext import io.sentry.TypeCheckHint import io.sentry.exception.SentryHttpClientException import io.sentry.mockServerRequestTimeoutMillis +import okhttp3.EventListener import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy @@ -75,6 +77,8 @@ class SentryOkHttpInterceptorTest { ) ), sendDefaultPii: Boolean = false, + eventListener: EventListener? = null, + additionalInterceptors: List = emptyList(), optionsConfiguration: Sentry.OptionsConfiguration? = null ): OkHttpClient { options = SentryOptions().also { @@ -120,7 +124,15 @@ class SentryOkHttpInterceptorTest { failedRequestStatusCodes = failedRequestStatusCodes ) } - return OkHttpClient.Builder().addInterceptor(interceptor).build() + return OkHttpClient.Builder().apply { + if (eventListener != null) { + eventListener(eventListener) + } + for (additionalInterceptor in additionalInterceptors) { + addInterceptor(additionalInterceptor) + } + addInterceptor(interceptor) + }.build() } } @@ -613,4 +625,29 @@ class SentryOkHttpInterceptorTest { call.execute() verify(event).finish() } + + @Test + fun `when an interceptor changes the request, the event is updated correctly`() { + val client = fixture.getSut( + eventListener = SentryOkHttpEventListener(fixture.scopes), + additionalInterceptors = listOf( + object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + return chain.proceed( + chain.request().newBuilder() + .url(chain.request().url.newBuilder().addPathSegment("v1").build()) + .build() + ) + } + } + ) + ) + + val request = getRequest("/hello/") + val call = client.newCall(request) + call.execute() + + val okHttpEvent = SentryOkHttpEventListener.eventMap[call]!! + assertEquals(fixture.server.url("/hello/v1").toUrl().toString(), okHttpEvent.callSpan!!.getData("url")) + } } From 083eb83c237b35f530d209d65cc56fa224ac6c20 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Mar 2025 10:19:15 +0100 Subject: [PATCH 023/914] Add param for running build before test run (#4232) * Also use port when checking if a request is made to Sentry DSN * changelog * Add a param to control whether the test script should rebuild before running the tested server --- .github/workflows/system-tests-backend.yml | 2 +- test/system-test-run-all.sh | 18 +++++++++--------- test/system-test-run.sh | 6 ++++++ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 0656a5331ef..93d007b15b9 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -101,7 +101,7 @@ jobs: - name: Start server and run integration test for sentry-cli commands run: | - test/system-test-run.sh "${{ matrix.sample }}" "${{ matrix.agent }}" "${{ matrix.agent-auto-init }}" + test/system-test-run.sh "${{ matrix.sample }}" "${{ matrix.agent }}" "${{ matrix.agent-auto-init }}" "0" - name: Upload test results if: always() diff --git a/test/system-test-run-all.sh b/test/system-test-run-all.sh index e65a500b4d6..cc3fb523670 100755 --- a/test/system-test-run-all.sh +++ b/test/system-test-run-all.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -./test/system-test-run.sh "sentry-samples-spring-boot" "0" "true" -./test/system-test-run.sh "sentry-samples-spring-boot-opentelemetry-noagent" "0" "true" -./test/system-test-run.sh "sentry-samples-spring-boot-opentelemetry" "1" "true" -./test/system-test-run.sh "sentry-samples-spring-boot-opentelemetry" "1" "false" -./test/system-test-run.sh "sentry-samples-spring-boot-webflux-jakarta" "0" "true" -./test/system-test-run.sh "sentry-samples-spring-boot-webflux" "0" "true" -./test/system-test-run.sh "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" "0" "true" -./test/system-test-run.sh "sentry-samples-spring-boot-jakarta-opentelemetry" "1" "true" -./test/system-test-run.sh "sentry-samples-spring-boot-jakarta-opentelemetry" "1" "false" +./test/system-test-run.sh "sentry-samples-spring-boot" "0" "true" "0" +./test/system-test-run.sh "sentry-samples-spring-boot-opentelemetry-noagent" "0" "true" "0" +./test/system-test-run.sh "sentry-samples-spring-boot-opentelemetry" "1" "true" "0" +./test/system-test-run.sh "sentry-samples-spring-boot-opentelemetry" "1" "false" "0" +./test/system-test-run.sh "sentry-samples-spring-boot-webflux-jakarta" "0" "true" "0" +./test/system-test-run.sh "sentry-samples-spring-boot-webflux" "0" "true" "0" +./test/system-test-run.sh "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" "0" "true" "0" +./test/system-test-run.sh "sentry-samples-spring-boot-jakarta-opentelemetry" "1" "true" "0" +./test/system-test-run.sh "sentry-samples-spring-boot-jakarta-opentelemetry" "1" "false" "0" diff --git a/test/system-test-run.sh b/test/system-test-run.sh index 7f1b47bed4f..9560beb3639 100755 --- a/test/system-test-run.sh +++ b/test/system-test-run.sh @@ -3,6 +3,12 @@ readonly SAMPLE_MODULE=$1 readonly JAVA_AGENT=$2 readonly JAVA_AGENT_AUTO_INIT=$3 +readonly BUILD_BEFORE_RUN=$4 + +if [[ "$BUILD_BEFORE_RUN" == "1" ]]; then + echo "Building before Test run" + ./gradlew :sentry-samples:${SAMPLE_MODULE}:assemble +fi test/system-test-sentry-server-start.sh MOCK_SERVER_PID=$(cat sentry-mock-server.pid) From a2c3d693d6bbfcf9b6fd9c4e8e5b7b7a1fa2ecbe Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Mar 2025 10:31:10 +0100 Subject: [PATCH 024/914] Add system tests for distributed tracing (#4233) * Also use port when checking if a request is made to Sentry DSN * changelog * Add a param to control whether the test script should rebuild before running the tested server * Add system tests for distributed tracing --- .../jakarta/DistributedTracingController.java | 51 +++++ .../DistributedTracingSystemTest.kt | 182 ++++++++++++++++++ .../systemtest/GraphqlGreetingSystemTest.kt | 4 +- .../systemtest/GraphqlProjectSystemTest.kt | 6 +- .../systemtest/GraphqlTaskSystemTest.kt | 2 +- .../io/sentry/systemtest/PersonSystemTest.kt | 4 +- .../io/sentry/systemtest/TodoSystemTest.kt | 6 +- .../sentry/systemtest/util/RestTestClient.kt | 36 +++- .../io/sentry/systemtest/util/TestHelper.kt | 75 ++++++-- 9 files changed, 336 insertions(+), 30 deletions(-) create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java new file mode 100644 index 00000000000..cfff0be4702 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java @@ -0,0 +1,51 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.opentelemetry.instrumentation.annotations.WithSpan; +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestClient restClient; + + public DistributedTracingController(RestClient restClient) { + this.restClient = restClient; + } + + @GetMapping("{id}") + @WithSpan("tracingSpanThroughOtelAnnotation") + Person person(@PathVariable Long id) { + return restClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restClient + .post() + .uri("http://localhost:8080/person/") + .body(person) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..3b4accef82a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,182 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.samples.spring.boot.jakarta.Person +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import org.springframework.http.HttpStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class DistributedTracingSystemTest { + + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + "$traceId-424cffc8f94feeee-1", + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + "$traceId-424cffc8f94feeee-0", + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + "$traceId-424cffc8f94feeee-1", + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + "$traceId-424cffc8f94feeee", + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPersonDistributedTracing( + person, + "$traceId-424cffc8f94feeee-1", + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index 769ae399bf0..5681c421a28 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -19,7 +19,7 @@ class GraphqlGreetingSystemTest { val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } @@ -32,7 +32,7 @@ class GraphqlGreetingSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt index 74b196e33b6..bfa38fead33 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -23,7 +23,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.project") } } @@ -34,7 +34,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertNotNull(response?.data?.addProject) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") } } @@ -48,7 +48,7 @@ class GraphqlProjectSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt index 7a9283ac05a..7b8a1471542 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -30,7 +30,7 @@ class GraphqlTaskSystemTest { assertEquals("C3", firstTask.creatorId) assertEquals("C3", firstTask.creator?.id) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.tasks") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 8d83bde6309..3eed9c69cad 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -23,7 +23,7 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") } @@ -39,7 +39,7 @@ class PersonSystemTest { assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index 4c8ee45ea64..a32735e7e6b 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -22,7 +22,7 @@ class TodoSystemTest { restClient.getTodo(1L) assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanSentryApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") @@ -35,7 +35,7 @@ class TodoSystemTest { restClient.getTodoWebclient(1L) assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } @@ -46,7 +46,7 @@ class TodoSystemTest { restClient.getTodoRestClient(1L) assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanSentryApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt index 1b1d16c841f..f50632f381c 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt @@ -33,6 +33,36 @@ class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestCl } } + fun getPersonDistributedTracing(id: Long, sentryTraceHeader: String? = null, baggageHeader: String? = null): Person? { + return try { + val response = restTemplate().exchange("$backendBaseUrl/tracing/{id}", HttpMethod.GET, entityWithAuth(headerCallback = tracingHeaders(sentryTraceHeader, baggageHeader)), Person::class.java, mapOf("id" to id)) + lastKnownStatusCode = response.statusCode + response.body + } catch (e: HttpStatusCodeException) { + lastKnownStatusCode = e.statusCode + null + } + } + + fun createPersonDistributedTracing(person: Person, sentryTraceHeader: String? = null, baggageHeader: String? = null): Person? { + return try { + val response = restTemplate().exchange("$backendBaseUrl/tracing/", HttpMethod.POST, entityWithAuth(person, tracingHeaders(sentryTraceHeader, baggageHeader)), Person::class.java, person) + lastKnownStatusCode = response.statusCode + response.body + } catch (e: HttpStatusCodeException) { + lastKnownStatusCode = e.statusCode + null + } + } + + private fun tracingHeaders(sentryTraceHeader: String?, baggageHeader: String?): (HttpHeaders) -> HttpHeaders { + return { httpHeaders -> + sentryTraceHeader?.let { httpHeaders.set("sentry-trace", it) } + baggageHeader?.let { httpHeaders.set("baggage", it) } + httpHeaders + } + } + fun getTodo(id: Long): Todo? { return try { val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) @@ -66,11 +96,13 @@ class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestCl } } - private fun entityWithAuth(request: Any? = null): HttpEntity { + private fun entityWithAuth(request: Any? = null, headerCallback: ((HttpHeaders) -> HttpHeaders)? = null): HttpEntity { val headers = HttpHeaders().also { it.setBasicAuth("user", "password") } - return HttpEntity(request, headers) + val modifiedHeaders = headerCallback?.invoke(headers) ?: headers + + return HttpEntity(request, modifiedHeaders) } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt index 12960f4c528..14bac5cd0ea 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -3,6 +3,7 @@ package io.sentry.systemtest.util import com.apollographql.apollo3.api.ApolloResponse import com.apollographql.apollo3.api.Operation import io.sentry.JsonSerializer +import io.sentry.SentryEnvelopeHeader import io.sentry.SentryEvent import io.sentry.SentryItemType import io.sentry.SentryOptions @@ -54,27 +55,55 @@ class TestHelper(backendUrl: String) { throw RuntimeException("Unable to find matching envelope received by relay") } - fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } + fun ensureNoEnvelopeReceived(callback: ((String) -> Boolean)) { + Thread.sleep(10000) + val envelopes = sentryClient.getEnvelopes() - val transactionItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } - if (transactionItem == null) { - return@ensureEnvelopeReceived false - } + if (envelopes.envelopes.isNullOrEmpty()) { + return + } - val transaction = transactionItem.getTransaction(jsonSerializer) - if (transaction == null) { - return@ensureEnvelopeReceived false + envelopes.envelopes.forEach { envelopeString -> + val didMatch = callback(envelopeString) + if (didMatch) { + throw RuntimeException("Found unexpected matching envelope received by relay") } + } + } + + fun ensureTransactionReceived(callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + checkIfTransactionMatches(envelopeString, callback) + } + } + + fun ensureNoTransactionReceived(callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean)) { + ensureNoEnvelopeReceived { envelopeString -> + checkIfTransactionMatches(envelopeString, callback) + } + } + + private fun checkIfTransactionMatches(envelopeString: String, callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean)): Boolean { + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return false + } + + val envelopeHeader = deserializeEnvelope.header + + val transactionItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } + if (transactionItem == null) { + return false + } - callback(transaction) + val transaction = transactionItem.getTransaction(jsonSerializer) + if (transaction == null) { + return false } + + return callback(transaction, envelopeHeader) } fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { @@ -106,7 +135,7 @@ class TestHelper(backendUrl: String) { } fun ensureTransactionWithSpanReceived(callback: ((SentrySpan) -> Boolean)) { - ensureTransactionReceived { transaction -> + ensureTransactionReceived { transaction, envelopeHeader -> transaction.spans.forEach { span -> val callbackResult = callback(span) if (callbackResult) { @@ -126,6 +155,7 @@ class TestHelper(backendUrl: String) { PrintWriter(System.out).use { jsonSerializer.serialize(obj, it) } + println() } fun ensureNoErrors(response: ApolloResponse?) { @@ -159,4 +189,15 @@ class TestHelper(backendUrl: String) { return true } + + fun doesTransactionHaveTraceId(transaction: SentryTransaction, traceId: String): Boolean { + val spanContext = transaction.contexts.trace + if (spanContext?.traceId?.toString() != traceId) { + println("Unable to find trace ID $traceId in transaction:") + logObject(transaction) + return false + } + + return true + } } From 251127d02e91c1c51c28e2a2c94d2d6485bafadb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Mar 2025 10:56:14 +0100 Subject: [PATCH 025/914] Reuse util classes for System tests (#4236) * Also use port when checking if a request is made to Sentry DSN * changelog * Add a param to control whether the test script should rebuild before running the tested server * Add system tests for distributed tracing * reuse util classes for system tests * add schema --- build.gradle.kts | 2 +- buildSrc/src/main/java/Config.kt | 1 + codecov.yml | 1 + .../build.gradle.kts | 12 +- .../DistributedTracingSystemTest.kt | 42 +- .../io/sentry/systemtest/PersonSystemTest.kt | 6 +- .../io/sentry/systemtest/TodoSystemTest.kt | 7 +- .../build.gradle.kts | 13 +- .../src/test/graphql/greeting.graphql | 3 - .../src/test/graphql/project.graphql | 11 - .../src/test/graphql/schema.graphqls | 70 --- .../src/test/graphql/task.graphql | 16 - .../systemtest/GraphqlGreetingSystemTest.kt | 4 +- .../systemtest/GraphqlProjectSystemTest.kt | 6 +- .../systemtest/GraphqlTaskSystemTest.kt | 2 +- .../io/sentry/systemtest/PersonSystemTest.kt | 10 +- .../io/sentry/systemtest/TodoSystemTest.kt | 13 +- .../systemtest/graphql/GraphqlTestClient.kt | 43 -- .../util/LoggingInsecureRestClient.kt | 13 - .../sentry/systemtest/util/RestTestClient.kt | 76 --- .../systemtest/util/SentryMockServerClient.kt | 43 -- .../io/sentry/systemtest/util/TestHelper.kt | 162 ----- .../build.gradle.kts | 13 +- .../src/test/graphql/greeting.graphql | 3 - .../src/test/graphql/project.graphql | 11 - .../src/test/graphql/schema.graphqls | 70 --- .../src/test/graphql/task.graphql | 16 - .../systemtest/GraphqlGreetingSystemTest.kt | 4 +- .../systemtest/GraphqlProjectSystemTest.kt | 6 +- .../systemtest/GraphqlTaskSystemTest.kt | 2 +- .../io/sentry/systemtest/PersonSystemTest.kt | 10 +- .../io/sentry/systemtest/TodoSystemTest.kt | 13 +- .../systemtest/graphql/GraphqlTestClient.kt | 43 -- .../util/LoggingInsecureRestClient.kt | 13 - .../sentry/systemtest/util/RestTestClient.kt | 76 --- .../systemtest/util/SentryMockServerClient.kt | 43 -- .../io/sentry/systemtest/util/TestHelper.kt | 173 ------ .../build.gradle.kts | 13 +- .../src/test/graphql/greeting.graphql | 3 - .../src/test/graphql/project.graphql | 11 - .../src/test/graphql/schema.graphqls | 70 --- .../src/test/graphql/task.graphql | 16 - .../systemtest/GraphqlGreetingSystemTest.kt | 4 +- .../systemtest/GraphqlProjectSystemTest.kt | 6 +- .../systemtest/GraphqlTaskSystemTest.kt | 2 +- .../io/sentry/systemtest/PersonSystemTest.kt | 10 +- .../io/sentry/systemtest/TodoSystemTest.kt | 9 +- .../systemtest/graphql/GraphqlTestClient.kt | 43 -- .../util/LoggingInsecureRestClient.kt | 32 - .../sentry/systemtest/util/RestTestClient.kt | 65 -- .../systemtest/util/SentryMockServerClient.kt | 43 -- .../io/sentry/systemtest/util/TestHelper.kt | 173 ------ .../build.gradle.kts | 13 +- .../src/test/graphql/greeting.graphql | 3 - .../src/test/graphql/project.graphql | 11 - .../src/test/graphql/schema.graphqls | 70 --- .../src/test/graphql/task.graphql | 16 - .../systemtest/GraphqlGreetingSystemTest.kt | 4 +- .../systemtest/GraphqlProjectSystemTest.kt | 6 +- .../systemtest/GraphqlTaskSystemTest.kt | 2 +- .../io/sentry/systemtest/PersonSystemTest.kt | 18 +- .../io/sentry/systemtest/TodoSystemTest.kt | 9 +- .../systemtest/graphql/GraphqlTestClient.kt | 43 -- .../util/LoggingInsecureRestClient.kt | 32 - .../sentry/systemtest/util/RestTestClient.kt | 68 --- .../systemtest/util/SentryMockServerClient.kt | 43 -- .../io/sentry/systemtest/util/TestHelper.kt | 173 ------ .../build.gradle.kts | 12 +- .../src/test/graphql/greeting.graphql | 3 - .../src/test/graphql/schema.graphqls | 3 - .../systemtest/GraphqlGreetingSystemTest.kt | 4 +- .../io/sentry/systemtest/PersonSystemTest.kt | 10 +- .../io/sentry/systemtest/TodoSystemTest.kt | 5 +- .../systemtest/graphql/GraphqlTestClient.kt | 28 - .../util/LoggingInsecureRestClient.kt | 13 - .../sentry/systemtest/util/RestTestClient.kt | 65 -- .../systemtest/util/SentryMockServerClient.kt | 43 -- .../io/sentry/systemtest/util/TestHelper.kt | 173 ------ .../build.gradle.kts | 13 +- .../src/test/graphql/greeting.graphql | 3 - .../src/test/graphql/schema.graphqls | 3 - .../systemtest/GraphqlGreetingSystemTest.kt | 4 +- .../io/sentry/systemtest/PersonSystemTest.kt | 10 +- .../io/sentry/systemtest/TodoSystemTest.kt | 5 +- .../systemtest/graphql/GraphqlTestClient.kt | 28 - .../util/LoggingInsecureRestClient.kt | 32 - .../sentry/systemtest/util/RestTestClient.kt | 65 -- .../systemtest/util/SentryMockServerClient.kt | 43 -- .../io/sentry/systemtest/util/TestHelper.kt | 173 ------ .../build.gradle.kts | 12 +- .../src/test/graphql/greeting.graphql | 3 - .../src/test/graphql/project.graphql | 11 - .../src/test/graphql/schema.graphqls | 70 --- .../src/test/graphql/task.graphql | 16 - .../systemtest/GraphqlGreetingSystemTest.kt | 4 +- .../systemtest/GraphqlProjectSystemTest.kt | 6 +- .../systemtest/GraphqlTaskSystemTest.kt | 2 +- .../io/sentry/systemtest/PersonSystemTest.kt | 10 +- .../io/sentry/systemtest/TodoSystemTest.kt | 9 +- .../systemtest/graphql/GraphqlTestClient.kt | 43 -- .../util/LoggingInsecureRestClient.kt | 32 - .../sentry/systemtest/util/RestTestClient.kt | 65 -- .../systemtest/util/SentryMockServerClient.kt | 43 -- .../io/sentry/systemtest/util/TestHelper.kt | 173 ------ .../api/sentry-system-test-support.api | 572 ++++++++++++++++++ sentry-system-test-support/build.gradle.kts | 54 ++ .../src/main}/graphql/greeting.graphql | 0 .../src/main}/graphql/project.graphql | 0 .../src/main}/graphql/schema.graphqls | 0 .../src/main}/graphql/task.graphql | 0 .../io/sentry/systemtest/ResponseTypes.kt | 10 + .../systemtest/graphql/GraphqlTestClient.kt | 0 .../util/LoggingInsecureRestClient.kt | 0 .../sentry/systemtest/util/RestTestClient.kt | 75 +-- .../systemtest/util/SentryMockServerClient.kt | 0 .../io/sentry/systemtest/util/TestHelper.kt | 11 + settings.gradle.kts | 1 + 117 files changed, 828 insertions(+), 3195 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/greeting.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/project.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/schema.graphqls delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/task.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/greeting.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/project.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/schema.graphqls delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/task.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/greeting.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/project.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/schema.graphqls delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/task.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/greeting.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/project.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/schema.graphqls delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/task.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/graphql/greeting.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/graphql/schema.graphqls delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/graphql/greeting.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/graphql/schema.graphqls delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/graphql/greeting.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/graphql/project.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/graphql/schema.graphqls delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/graphql/task.graphql delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt delete mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt create mode 100644 sentry-system-test-support/api/sentry-system-test-support.api create mode 100644 sentry-system-test-support/build.gradle.kts rename {sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test => sentry-system-test-support/src/main}/graphql/greeting.graphql (100%) rename {sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test => sentry-system-test-support/src/main}/graphql/project.graphql (100%) rename {sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test => sentry-system-test-support/src/main}/graphql/schema.graphqls (100%) rename {sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test => sentry-system-test-support/src/main}/graphql/task.graphql (100%) create mode 100644 sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt rename {sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test => sentry-system-test-support/src/main}/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt (100%) rename {sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test => sentry-system-test-support/src/main}/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt (100%) rename {sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test => sentry-system-test-support/src/main}/kotlin/io/sentry/systemtest/util/RestTestClient.kt (51%) rename {sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test => sentry-system-test-support/src/main}/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt (100%) rename {sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test => sentry-system-test-support/src/main}/kotlin/io/sentry/systemtest/util/TestHelper.kt (95%) diff --git a/build.gradle.kts b/build.gradle.kts index 1b2eb5614b9..38ff7b04393 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -155,7 +155,7 @@ subprojects { } } - if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-test-support" && this.name != "sentry-compose-helper") { + if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-system-test-support" && this.name != "sentry-test-support" && this.name != "sentry-compose-helper") { apply() apply() diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 03b6849a6ba..b0e56eb2dc4 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -75,6 +75,7 @@ object Config { val log4j2Core = "org.apache.logging.log4j:log4j-core:$log4j2Version" val jacksonDatabind = "com.fasterxml.jackson.core:jackson-databind" + val jacksonKotlin = "com.fasterxml.jackson.module:jackson-module-kotlin:2.18.3" val springBootStarter = "org.springframework.boot:spring-boot-starter:$springBootVersion" val springBootStarterGraphql = "org.springframework.boot:spring-boot-starter-graphql:$springBootVersion" diff --git a/codecov.yml b/codecov.yml index 7dd03ca5e85..66a0719b606 100644 --- a/codecov.yml +++ b/codecov.yml @@ -16,5 +16,6 @@ coverage: ignore: - "**/src/test/*" - "sentry-android-integration-tests/*" + - "sentry-system-test-support/*" - "sentry-test-support/*" - "sentry-samples/*" diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index bd08b78c048..1f31fbfc05f 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -6,7 +6,6 @@ plugins { id(Config.BuildPlugins.springDependencyManagement) version Config.BuildPlugins.springDependencyManagementVersion kotlin("jvm") kotlin("plugin.spring") version Config.kotlinVersion - id("com.apollographql.apollo3") version "3.8.2" } group = "io.sentry.sample.spring-boot-jakarta" @@ -57,6 +56,7 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(Config.TestLibs.hsqldb) + testImplementation(projects.sentrySystemTestSupport) testImplementation(Config.Libs.springBoot3StarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } @@ -103,13 +103,3 @@ tasks.named("test").configure { excludeTestsMatching("io.sentry.systemtest.*") } } - -apollo { - service("service") { - srcDir("src/test/graphql") - packageName.set("io.sentry.samples.graphql") - outputDirConnection { - connectToKotlinSourceSet("test") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt index 3b4accef82a..aa707f8b6e4 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -1,10 +1,8 @@ package io.sentry.systemtest import io.sentry.protocol.SentryId -import io.sentry.samples.spring.boot.jakarta.Person import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals @@ -25,10 +23,12 @@ class DistributedTracingSystemTest { val restClient = testHelper.restClient restClient.getPersonDistributedTracing( 1L, - "$traceId-424cffc8f94feeee-1", - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) ) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) testHelper.ensureTransactionReceived { transaction, envelopeHeader -> transaction.transaction == "GET /tracing/{id}" && @@ -47,10 +47,12 @@ class DistributedTracingSystemTest { val restClient = testHelper.restClient restClient.getPersonDistributedTracing( 1L, - "$traceId-424cffc8f94feeee-0", - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) ) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> transaction.transaction == "GET /tracing/{id}" @@ -67,10 +69,12 @@ class DistributedTracingSystemTest { val restClient = testHelper.restClient restClient.getPersonDistributedTracing( 1L, - "$traceId-424cffc8f94feeee-1", - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) ) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) var sampleRand1: String? = null var sampleRand2: String? = null @@ -113,10 +117,12 @@ class DistributedTracingSystemTest { val restClient = testHelper.restClient restClient.getPersonDistributedTracing( 1L, - "$traceId-424cffc8f94feeee", - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) ) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) var sampleRate1: String? = null var sampleRate2: String? = null @@ -161,10 +167,12 @@ class DistributedTracingSystemTest { val person = Person("firstA", "lastB") val returnedPerson = restClient.createPersonDistributedTracing( person, - "$traceId-424cffc8f94feeee-1", - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) ) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 3eed9c69cad..4f7661dc4b3 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,9 +1,7 @@ package io.sentry.systemtest -import io.sentry.samples.spring.boot.jakarta.Person import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -21,7 +19,7 @@ class PersonSystemTest { fun `get person fails`() { val restClient = testHelper.restClient restClient.getPerson(1L) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && @@ -34,7 +32,7 @@ class PersonSystemTest { val restClient = testHelper.restClient val person = Person("firstA", "lastB") val returnedPerson = restClient.createPerson(person) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index a32735e7e6b..80b9bd4f310 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -2,7 +2,6 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -20,7 +19,7 @@ class TodoSystemTest { fun `get todo works`() { val restClient = testHelper.restClient restClient.getTodo(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanOtelApi") && @@ -33,7 +32,7 @@ class TodoSystemTest { fun `get todo webclient works`() { val restClient = testHelper.restClient restClient.getTodoWebclient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") @@ -44,7 +43,7 @@ class TodoSystemTest { fun `get todo restclient works`() { val restClient = testHelper.restClient restClient.getTodoRestClient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanOtelApi") && diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index acfc0d24d9e..dce546c8c4e 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -7,7 +7,6 @@ plugins { id(Config.BuildPlugins.springDependencyManagement) version Config.BuildPlugins.springDependencyManagementVersion kotlin("jvm") kotlin("plugin.spring") version Config.kotlinVersion - id("com.apollographql.apollo3") version "3.8.2" } group = "io.sentry.sample.spring-boot-jakarta" @@ -58,6 +57,8 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(Config.TestLibs.hsqldb) + + testImplementation(projects.sentrySystemTestSupport) testImplementation(Config.Libs.springBoot3StarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } @@ -123,13 +124,3 @@ tasks.named("test").configure { excludeTestsMatching("io.sentry.systemtest.*") } } - -apollo { - service("service") { - srcDir("src/test/graphql") - packageName.set("io.sentry.samples.graphql") - outputDirConnection { - connectToKotlinSourceSet("test") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/greeting.graphql b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/greeting.graphql deleted file mode 100644 index 06c866a65fa..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/greeting.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query GreetingQuery($name: String!) { - greeting(name: $name) -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/project.graphql b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/project.graphql deleted file mode 100644 index bff62ed2c2c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/project.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query ProjectQuery($slug: ID!) { - project(slug: $slug) { - slug - name - status - } -} - -mutation AddProjectMutation($slug: ID!) { - addProject(slug: $slug) -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/schema.graphqls deleted file mode 100644 index d76aca4756a..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/schema.graphqls +++ /dev/null @@ -1,70 +0,0 @@ -type Query { - greeting(name: String! = "Spring"): String! - project(slug: ID!): Project - tasks(projectSlug: ID!): [Task] -} - -type Mutation { - addProject(slug: ID!): String! -} - -type Subscription { - notifyNewTask(projectSlug: ID!): Task -} - -""" A Project in the Spring portfolio """ -type Project { - """ Unique string id used in URLs """ - slug: ID! - """ Project name """ - name: String - """ URL of the git repository """ - repositoryUrl: String! - """ Current support status """ - status: ProjectStatus! -} - -""" A task """ -type Task { - """ ID """ - id: String! - """ Name """ - name: String! - """ ID of the Assignee """ - assigneeId: String - """ Assignee """ - assignee: Assignee - """ ID of the Creator """ - creatorId: String - """ Creator """ - creator: Creator -} - -""" An Assignee """ -type Assignee { - """ ID """ - id: String! - """ Name """ - name: String! -} - -""" An Creator """ -type Creator { - """ ID """ - id: String! - """ Name """ - name: String! -} - -enum ProjectStatus { - """ Actively supported by the Spring team """ - ACTIVE - """ Supported by the community """ - COMMUNITY - """ Prototype, not officially supported yet """ - INCUBATING - """ Project being retired, in maintenance mode """ - ATTIC - """ End-Of-Lifed """ - EOL -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/task.graphql b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/task.graphql deleted file mode 100644 index 11ae18574d3..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/task.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query TasksAndAssigneesQuery($slug: ID!) { - tasks(projectSlug: $slug) { - id - name - assigneeId - assignee { - id - name - } - creatorId - creator { - id - name - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index b60f2b113a3..b4122d32311 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -19,7 +19,7 @@ class GraphqlGreetingSystemTest { val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "query GreetingQuery") } } @@ -32,7 +32,7 @@ class GraphqlGreetingSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "query GreetingQuery") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt index 8946284be43..6452bdaf1ca 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -23,7 +23,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "query ProjectQuery") } } @@ -34,7 +34,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertNotNull(response?.data?.addProject) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "mutation AddProjectMutation") } } @@ -48,7 +48,7 @@ class GraphqlProjectSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "mutation AddProjectMutation") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt index 2fba967c824..38343e5b0e5 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -30,7 +30,7 @@ class GraphqlTaskSystemTest { assertEquals("C3", firstTask.creatorId) assertEquals("C3", firstTask.creator?.id) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "query TasksAndAssigneesQuery") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 8d83bde6309..4f7661dc4b3 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,9 +1,7 @@ package io.sentry.systemtest -import io.sentry.samples.spring.boot.jakarta.Person import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -21,9 +19,9 @@ class PersonSystemTest { fun `get person fails`() { val restClient = testHelper.restClient restClient.getPerson(1L) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") } @@ -34,12 +32,12 @@ class PersonSystemTest { val restClient = testHelper.restClient val person = Person("firstA", "lastB") val returnedPerson = restClient.createPerson(person) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index 4c8ee45ea64..80b9bd4f310 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -2,7 +2,6 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -20,9 +19,9 @@ class TodoSystemTest { fun `get todo works`() { val restClient = testHelper.restClient restClient.getTodo(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanSentryApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") @@ -33,9 +32,9 @@ class TodoSystemTest { fun `get todo webclient works`() { val restClient = testHelper.restClient restClient.getTodoWebclient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } @@ -44,9 +43,9 @@ class TodoSystemTest { fun `get todo restclient works`() { val restClient = testHelper.restClient restClient.getTodoRestClient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanSentryApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt deleted file mode 100644 index 0c11906292b..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.graphql - -import com.apollographql.apollo3.ApolloClient -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Mutation -import com.apollographql.apollo3.api.Query -import io.sentry.samples.graphql.AddProjectMutation -import io.sentry.samples.graphql.GreetingQuery -import io.sentry.samples.graphql.ProjectQuery -import io.sentry.samples.graphql.TasksAndAssigneesQuery -import kotlinx.coroutines.runBlocking - -class GraphqlTestClient(backendUrl: String) { - - val apollo = ApolloClient.Builder() - .serverUrl("$backendUrl/graphql") - .addHttpHeader("Authorization", "Basic dXNlcjpwYXNzd29yZA==") - .build() - - fun greet(name: String): ApolloResponse? { - return executeQuery(GreetingQuery(name)) - } - - fun project(slug: String): ApolloResponse? { - return executeQuery(ProjectQuery(slug)) - } - - fun tasksAndAssignees(slug: String): ApolloResponse? { - return executeQuery(TasksAndAssigneesQuery(slug)) - } - - fun addProject(slug: String): ApolloResponse? { - return executeMutation(AddProjectMutation(slug)) - } - - private fun executeQuery(query: Query): ApolloResponse? = runBlocking { - apollo.query(query).execute() - } - - private fun executeMutation(mutation: Mutation): ApolloResponse? = runBlocking { - apollo.mutation(mutation).execute() - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt deleted file mode 100644 index 17eea1a0084..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.sentry.systemtest.util - -import org.springframework.http.client.BufferingClientHttpRequestFactory -import org.springframework.web.client.RestTemplate - -open class LoggingInsecureRestClient { - - protected fun restTemplate(): RestTemplate { - return RestTemplate().also { - it.requestFactory = BufferingClientHttpRequestFactory(it.requestFactory) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt deleted file mode 100644 index 1b1d16c841f..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ /dev/null @@ -1,76 +0,0 @@ -package io.sentry.systemtest.util - -import io.sentry.samples.spring.boot.jakarta.Person -import io.sentry.samples.spring.boot.jakarta.Todo -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatusCode -import org.springframework.web.client.HttpStatusCodeException - -class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - var lastKnownStatusCode: HttpStatusCode? = null - - fun getPerson(id: Long): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/{id}", HttpMethod.GET, entityWithAuth(), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun createPerson(person: Person): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person), Person::class.java, person) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodo(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodoWebclient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-webclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodoRestClient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-restclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders().also { - it.setBasicAuth("user", "password") - } - - return HttpEntity(request, headers) - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt deleted file mode 100644 index 7ef1699f122..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.util - -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod - -class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { - - fun getEnvelopeCount(): EnvelopeCounts { - val response = restTemplate().exchange("$baseUrl/envelope-count", HttpMethod.GET, entityWithAuth(), EnvelopeCounts::class.java) - return response.body!! - } - - fun reset() { - restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) - } - - fun getEnvelopes(): EnvelopesReceived { - val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) - return response.body!! - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders() - return HttpEntity(request, headers) - } -} - -class EnvelopeCounts { - val envelopes: Long? = null - - override fun toString(): String { - return "EnvelopeCounts{envelopes=$envelopes}" - } -} - -class EnvelopesReceived { - val envelopes: List? = null - - override fun toString(): String { - return "EnvelopesReceived{envelopes=$envelopes}" - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt deleted file mode 100644 index 12960f4c528..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ /dev/null @@ -1,162 +0,0 @@ -package io.sentry.systemtest.util - -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Operation -import io.sentry.JsonSerializer -import io.sentry.SentryEvent -import io.sentry.SentryItemType -import io.sentry.SentryOptions -import io.sentry.protocol.SentrySpan -import io.sentry.protocol.SentryTransaction -import io.sentry.systemtest.graphql.GraphqlTestClient -import java.io.PrintWriter -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class TestHelper(backendUrl: String) { - - val restClient: RestTestClient - val graphqlClient: GraphqlTestClient - val sentryClient: SentryMockServerClient - val jsonSerializer: JsonSerializer - - var envelopeCounts: EnvelopeCounts? = null - - init { - restClient = RestTestClient(backendUrl) - sentryClient = SentryMockServerClient("http://localhost:8000") - graphqlClient = GraphqlTestClient(backendUrl) - jsonSerializer = JsonSerializer(SentryOptions.empty()) - } - - fun snapshotEnvelopeCount() { - envelopeCounts = sentryClient.getEnvelopeCount() - } - - fun ensureEnvelopeCountIncreased() { - Thread.sleep(1000) - val envelopeCountsAfter = sentryClient.getEnvelopeCount() - assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) - } - - fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { - Thread.sleep(10000) - val envelopes = sentryClient.getEnvelopes() - assertNotNull(envelopes.envelopes) - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - return - } - } - throw RuntimeException("Unable to find matching envelope received by relay") - } - - fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val transactionItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } - if (transactionItem == null) { - return@ensureEnvelopeReceived false - } - - val transaction = transactionItem.getTransaction(jsonSerializer) - if (transaction == null) { - return@ensureEnvelopeReceived false - } - - callback(transaction) - } - } - - fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val errorItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } - if (errorItem == null) { - return@ensureEnvelopeReceived false - } - - val error = errorItem.getEvent(jsonSerializer) - if (error == null) { - return@ensureEnvelopeReceived false - } - - val callbackResult = callback(error) - if (!callbackResult) { - println("found an error event but it did not match:") - logObject(error) - } - callbackResult - } - } - - fun ensureTransactionWithSpanReceived(callback: ((SentrySpan) -> Boolean)) { - ensureTransactionReceived { transaction -> - transaction.spans.forEach { span -> - val callbackResult = callback(span) - if (callbackResult) { - return@ensureTransactionReceived true - } - } - false - } - } - - fun reset() { - sentryClient.reset() - } - - fun logObject(obj: Any?) { - obj ?: return - PrintWriter(System.out).use { - jsonSerializer.serialize(obj, it) - } - } - - fun ensureNoErrors(response: ApolloResponse?) { - response ?: throw RuntimeException("no response") - assertFalse(response.hasErrors()) - } - - fun ensureErrorCount(response: ApolloResponse?, errorCount: Int) { - response ?: throw RuntimeException("no response") - assertEquals(errorCount, response.errors?.size) - } - - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.op == op } - if (span == null) { - println("Unable to find span with op $op in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.description == description } - if (span == null) { - println("Unable to find span with description $description in transaction:") - logObject(transaction) - return false - } - - return true - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 242859656c3..11e6b613466 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ plugins { id(Config.BuildPlugins.springDependencyManagement) version Config.BuildPlugins.springDependencyManagementVersion kotlin("jvm") kotlin("plugin.spring") version Config.kotlinVersion - id("com.apollographql.apollo3") version "3.8.2" } group = "io.sentry.sample.spring-boot-jakarta" @@ -56,6 +55,8 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(Config.TestLibs.hsqldb) + + testImplementation(projects.sentrySystemTestSupport) testImplementation(Config.Libs.springBoot3StarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } @@ -97,13 +98,3 @@ tasks.named("test").configure { excludeTestsMatching("io.sentry.systemtest.*") } } - -apollo { - service("service") { - srcDir("src/test/graphql") - packageName.set("io.sentry.samples.graphql") - outputDirConnection { - connectToKotlinSourceSet("test") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/greeting.graphql b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/greeting.graphql deleted file mode 100644 index 06c866a65fa..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/greeting.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query GreetingQuery($name: String!) { - greeting(name: $name) -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/project.graphql b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/project.graphql deleted file mode 100644 index bff62ed2c2c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/project.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query ProjectQuery($slug: ID!) { - project(slug: $slug) { - slug - name - status - } -} - -mutation AddProjectMutation($slug: ID!) { - addProject(slug: $slug) -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/schema.graphqls deleted file mode 100644 index d76aca4756a..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/schema.graphqls +++ /dev/null @@ -1,70 +0,0 @@ -type Query { - greeting(name: String! = "Spring"): String! - project(slug: ID!): Project - tasks(projectSlug: ID!): [Task] -} - -type Mutation { - addProject(slug: ID!): String! -} - -type Subscription { - notifyNewTask(projectSlug: ID!): Task -} - -""" A Project in the Spring portfolio """ -type Project { - """ Unique string id used in URLs """ - slug: ID! - """ Project name """ - name: String - """ URL of the git repository """ - repositoryUrl: String! - """ Current support status """ - status: ProjectStatus! -} - -""" A task """ -type Task { - """ ID """ - id: String! - """ Name """ - name: String! - """ ID of the Assignee """ - assigneeId: String - """ Assignee """ - assignee: Assignee - """ ID of the Creator """ - creatorId: String - """ Creator """ - creator: Creator -} - -""" An Assignee """ -type Assignee { - """ ID """ - id: String! - """ Name """ - name: String! -} - -""" An Creator """ -type Creator { - """ ID """ - id: String! - """ Name """ - name: String! -} - -enum ProjectStatus { - """ Actively supported by the Spring team """ - ACTIVE - """ Supported by the community """ - COMMUNITY - """ Prototype, not officially supported yet """ - INCUBATING - """ Project being retired, in maintenance mode """ - ATTIC - """ End-Of-Lifed """ - EOL -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/task.graphql b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/task.graphql deleted file mode 100644 index 11ae18574d3..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/graphql/task.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query TasksAndAssigneesQuery($slug: ID!) { - tasks(projectSlug: $slug) { - id - name - assigneeId - assignee { - id - name - } - creatorId - creator { - id - name - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index 769ae399bf0..5681c421a28 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -19,7 +19,7 @@ class GraphqlGreetingSystemTest { val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } @@ -32,7 +32,7 @@ class GraphqlGreetingSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt index 74b196e33b6..bfa38fead33 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -23,7 +23,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.project") } } @@ -34,7 +34,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertNotNull(response?.data?.addProject) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") } } @@ -48,7 +48,7 @@ class GraphqlProjectSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt index cf2aebfd090..0f634f309bd 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -30,7 +30,7 @@ class GraphqlTaskSystemTest { assertEquals("C3", firstTask.creatorId) assertEquals("C3", firstTask.creator?.id) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.tasks") && testHelper.doesTransactionContainSpanWithDescription(transaction, "Task.assignee") && testHelper.doesTransactionContainSpanWithDescription(transaction, "Task.creator") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index e4a388ef529..c190b86d6b0 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,9 +1,7 @@ package io.sentry.systemtest -import io.sentry.samples.spring.boot.jakarta.Person import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -21,9 +19,9 @@ class PersonSystemTest { fun `get person fails`() { val restClient = testHelper.restClient restClient.getPerson(1L) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } } @@ -33,12 +31,12 @@ class PersonSystemTest { val restClient = testHelper.restClient val person = Person("firstA", "lastB") val returnedPerson = restClient.createPerson(person) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && testHelper.doesTransactionContainSpanWithOp(transaction, "db.query") } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index 9893bcb3966..3ef84872a81 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -2,7 +2,6 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -20,9 +19,9 @@ class TodoSystemTest { fun `get todo works`() { val restClient = testHelper.restClient restClient.getTodo(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } @@ -31,9 +30,9 @@ class TodoSystemTest { fun `get todo webclient works`() { val restClient = testHelper.restClient restClient.getTodoWebclient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } @@ -42,9 +41,9 @@ class TodoSystemTest { fun `get todo restclient works`() { val restClient = testHelper.restClient restClient.getTodoRestClient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt deleted file mode 100644 index 0c11906292b..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.graphql - -import com.apollographql.apollo3.ApolloClient -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Mutation -import com.apollographql.apollo3.api.Query -import io.sentry.samples.graphql.AddProjectMutation -import io.sentry.samples.graphql.GreetingQuery -import io.sentry.samples.graphql.ProjectQuery -import io.sentry.samples.graphql.TasksAndAssigneesQuery -import kotlinx.coroutines.runBlocking - -class GraphqlTestClient(backendUrl: String) { - - val apollo = ApolloClient.Builder() - .serverUrl("$backendUrl/graphql") - .addHttpHeader("Authorization", "Basic dXNlcjpwYXNzd29yZA==") - .build() - - fun greet(name: String): ApolloResponse? { - return executeQuery(GreetingQuery(name)) - } - - fun project(slug: String): ApolloResponse? { - return executeQuery(ProjectQuery(slug)) - } - - fun tasksAndAssignees(slug: String): ApolloResponse? { - return executeQuery(TasksAndAssigneesQuery(slug)) - } - - fun addProject(slug: String): ApolloResponse? { - return executeMutation(AddProjectMutation(slug)) - } - - private fun executeQuery(query: Query): ApolloResponse? = runBlocking { - apollo.query(query).execute() - } - - private fun executeMutation(mutation: Mutation): ApolloResponse? = runBlocking { - apollo.mutation(mutation).execute() - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt deleted file mode 100644 index 17eea1a0084..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.sentry.systemtest.util - -import org.springframework.http.client.BufferingClientHttpRequestFactory -import org.springframework.web.client.RestTemplate - -open class LoggingInsecureRestClient { - - protected fun restTemplate(): RestTemplate { - return RestTemplate().also { - it.requestFactory = BufferingClientHttpRequestFactory(it.requestFactory) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt deleted file mode 100644 index 1b1d16c841f..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ /dev/null @@ -1,76 +0,0 @@ -package io.sentry.systemtest.util - -import io.sentry.samples.spring.boot.jakarta.Person -import io.sentry.samples.spring.boot.jakarta.Todo -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatusCode -import org.springframework.web.client.HttpStatusCodeException - -class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - var lastKnownStatusCode: HttpStatusCode? = null - - fun getPerson(id: Long): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/{id}", HttpMethod.GET, entityWithAuth(), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun createPerson(person: Person): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person), Person::class.java, person) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodo(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodoWebclient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-webclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodoRestClient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-restclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders().also { - it.setBasicAuth("user", "password") - } - - return HttpEntity(request, headers) - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt deleted file mode 100644 index 7ef1699f122..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.util - -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod - -class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { - - fun getEnvelopeCount(): EnvelopeCounts { - val response = restTemplate().exchange("$baseUrl/envelope-count", HttpMethod.GET, entityWithAuth(), EnvelopeCounts::class.java) - return response.body!! - } - - fun reset() { - restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) - } - - fun getEnvelopes(): EnvelopesReceived { - val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) - return response.body!! - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders() - return HttpEntity(request, headers) - } -} - -class EnvelopeCounts { - val envelopes: Long? = null - - override fun toString(): String { - return "EnvelopeCounts{envelopes=$envelopes}" - } -} - -class EnvelopesReceived { - val envelopes: List? = null - - override fun toString(): String { - return "EnvelopesReceived{envelopes=$envelopes}" - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt deleted file mode 100644 index 1017d847348..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ /dev/null @@ -1,173 +0,0 @@ -package io.sentry.systemtest.util - -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Operation -import io.sentry.JsonSerializer -import io.sentry.SentryEvent -import io.sentry.SentryItemType -import io.sentry.SentryOptions -import io.sentry.protocol.SentrySpan -import io.sentry.protocol.SentryTransaction -import io.sentry.systemtest.graphql.GraphqlTestClient -import java.io.PrintWriter -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class TestHelper(backendUrl: String) { - - val restClient: RestTestClient - val graphqlClient: GraphqlTestClient - val sentryClient: SentryMockServerClient - val jsonSerializer: JsonSerializer - - var envelopeCounts: EnvelopeCounts? = null - - init { - restClient = RestTestClient(backendUrl) - sentryClient = SentryMockServerClient("http://localhost:8000") - graphqlClient = GraphqlTestClient(backendUrl) - jsonSerializer = JsonSerializer(SentryOptions.empty()) - } - - fun snapshotEnvelopeCount() { - envelopeCounts = sentryClient.getEnvelopeCount() - } - - fun ensureEnvelopeCountIncreased() { - Thread.sleep(1000) - val envelopeCountsAfter = sentryClient.getEnvelopeCount() - assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) - } - - fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { - Thread.sleep(10000) - val envelopes = sentryClient.getEnvelopes() - assertNotNull(envelopes.envelopes) - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - return - } - } - throw RuntimeException("Unable to find matching envelope received by relay") - } - - fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val transactionItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } - if (transactionItem == null) { - return@ensureEnvelopeReceived false - } - - val transaction = transactionItem.getTransaction(jsonSerializer) - if (transaction == null) { - return@ensureEnvelopeReceived false - } - - callback(transaction) - } - } - - fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val errorItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } - if (errorItem == null) { - return@ensureEnvelopeReceived false - } - - val error = errorItem.getEvent(jsonSerializer) - if (error == null) { - return@ensureEnvelopeReceived false - } - - val callbackResult = callback(error) - if (!callbackResult) { - println("found an error event but it did not match:") - logObject(error) - } - callbackResult - } - } - - fun ensureTransactionWithSpanReceived(callback: ((SentrySpan) -> Boolean)) { - ensureTransactionReceived { transaction -> - transaction.spans.forEach { span -> - val callbackResult = callback(span) - if (callbackResult) { - return@ensureTransactionReceived true - } - } - false - } - } - - fun reset() { - sentryClient.reset() - } - - fun logObject(obj: Any?) { - obj ?: return - PrintWriter(System.out).use { - jsonSerializer.serialize(obj, it) - } - } - - fun ensureNoErrors(response: ApolloResponse?) { - response ?: throw RuntimeException("no response") - assertFalse(response.hasErrors()) - } - - fun ensureErrorCount(response: ApolloResponse?, errorCount: Int) { - response ?: throw RuntimeException("no response") - assertEquals(errorCount, response.errors?.size) - } - - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.op == op } - if (span == null) { - println("Unable to find span with op $op in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.description == description } - if (span == null) { - println("Unable to find span with description $description in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { - val matches = transaction.contexts.trace?.operation == op - if (!matches) { - println("Unable to find transaction with op $op:") - logObject(transaction) - return false - } - - return true - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index c6dd41951cc..fa30b6d3b16 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -6,7 +6,6 @@ plugins { id(Config.BuildPlugins.springDependencyManagement) version Config.BuildPlugins.springDependencyManagementVersion kotlin("jvm") kotlin("plugin.spring") version Config.kotlinVersion - id("com.apollographql.apollo3") version "3.8.2" } group = "io.sentry.sample.spring-boot" @@ -57,6 +56,8 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(Config.TestLibs.hsqldb) + + testImplementation(projects.sentrySystemTestSupport) testImplementation(Config.Libs.springBootStarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } @@ -104,13 +105,3 @@ tasks.named("test").configure { excludeTestsMatching("io.sentry.systemtest.*") } } - -apollo { - service("service") { - srcDir("src/test/graphql") - packageName.set("io.sentry.samples.graphql") - outputDirConnection { - connectToKotlinSourceSet("test") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/greeting.graphql b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/greeting.graphql deleted file mode 100644 index 06c866a65fa..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/greeting.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query GreetingQuery($name: String!) { - greeting(name: $name) -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/project.graphql b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/project.graphql deleted file mode 100644 index bff62ed2c2c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/project.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query ProjectQuery($slug: ID!) { - project(slug: $slug) { - slug - name - status - } -} - -mutation AddProjectMutation($slug: ID!) { - addProject(slug: $slug) -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/schema.graphqls deleted file mode 100644 index d76aca4756a..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/schema.graphqls +++ /dev/null @@ -1,70 +0,0 @@ -type Query { - greeting(name: String! = "Spring"): String! - project(slug: ID!): Project - tasks(projectSlug: ID!): [Task] -} - -type Mutation { - addProject(slug: ID!): String! -} - -type Subscription { - notifyNewTask(projectSlug: ID!): Task -} - -""" A Project in the Spring portfolio """ -type Project { - """ Unique string id used in URLs """ - slug: ID! - """ Project name """ - name: String - """ URL of the git repository """ - repositoryUrl: String! - """ Current support status """ - status: ProjectStatus! -} - -""" A task """ -type Task { - """ ID """ - id: String! - """ Name """ - name: String! - """ ID of the Assignee """ - assigneeId: String - """ Assignee """ - assignee: Assignee - """ ID of the Creator """ - creatorId: String - """ Creator """ - creator: Creator -} - -""" An Assignee """ -type Assignee { - """ ID """ - id: String! - """ Name """ - name: String! -} - -""" An Creator """ -type Creator { - """ ID """ - id: String! - """ Name """ - name: String! -} - -enum ProjectStatus { - """ Actively supported by the Spring team """ - ACTIVE - """ Supported by the community """ - COMMUNITY - """ Prototype, not officially supported yet """ - INCUBATING - """ Project being retired, in maintenance mode """ - ATTIC - """ End-Of-Lifed """ - EOL -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/task.graphql b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/task.graphql deleted file mode 100644 index 11ae18574d3..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/graphql/task.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query TasksAndAssigneesQuery($slug: ID!) { - tasks(projectSlug: $slug) { - id - name - assigneeId - assignee { - id - name - } - creatorId - creator { - id - name - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index 769ae399bf0..5681c421a28 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -19,7 +19,7 @@ class GraphqlGreetingSystemTest { val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } @@ -32,7 +32,7 @@ class GraphqlGreetingSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt index 74b196e33b6..bfa38fead33 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -23,7 +23,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.project") } } @@ -34,7 +34,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertNotNull(response?.data?.addProject) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") } } @@ -48,7 +48,7 @@ class GraphqlProjectSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") } } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt index 7a9283ac05a..7b8a1471542 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -30,7 +30,7 @@ class GraphqlTaskSystemTest { assertEquals("C3", firstTask.creatorId) assertEquals("C3", firstTask.creator?.id) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.tasks") } } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 9309529ce44..4f7661dc4b3 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,9 +1,7 @@ package io.sentry.systemtest -import io.sentry.samples.spring.boot.Person import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -21,9 +19,9 @@ class PersonSystemTest { fun `get person fails`() { val restClient = testHelper.restClient restClient.getPerson(1L) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") } @@ -34,12 +32,12 @@ class PersonSystemTest { val restClient = testHelper.restClient val person = Person("firstA", "lastB") val returnedPerson = restClient.createPerson(person) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index a48ea15fdd5..8b472ede78e 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -2,7 +2,6 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -20,9 +19,9 @@ class TodoSystemTest { fun `get todo works`() { val restClient = testHelper.restClient restClient.getTodo(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanSentryApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") @@ -33,9 +32,9 @@ class TodoSystemTest { fun `get todo webclient works`() { val restClient = testHelper.restClient restClient.getTodoWebclient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt deleted file mode 100644 index 0c11906292b..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.graphql - -import com.apollographql.apollo3.ApolloClient -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Mutation -import com.apollographql.apollo3.api.Query -import io.sentry.samples.graphql.AddProjectMutation -import io.sentry.samples.graphql.GreetingQuery -import io.sentry.samples.graphql.ProjectQuery -import io.sentry.samples.graphql.TasksAndAssigneesQuery -import kotlinx.coroutines.runBlocking - -class GraphqlTestClient(backendUrl: String) { - - val apollo = ApolloClient.Builder() - .serverUrl("$backendUrl/graphql") - .addHttpHeader("Authorization", "Basic dXNlcjpwYXNzd29yZA==") - .build() - - fun greet(name: String): ApolloResponse? { - return executeQuery(GreetingQuery(name)) - } - - fun project(slug: String): ApolloResponse? { - return executeQuery(ProjectQuery(slug)) - } - - fun tasksAndAssignees(slug: String): ApolloResponse? { - return executeQuery(TasksAndAssigneesQuery(slug)) - } - - fun addProject(slug: String): ApolloResponse? { - return executeMutation(AddProjectMutation(slug)) - } - - private fun executeQuery(query: Query): ApolloResponse? = runBlocking { - apollo.query(query).execute() - } - - private fun executeMutation(mutation: Mutation): ApolloResponse? = runBlocking { - apollo.mutation(mutation).execute() - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt deleted file mode 100644 index 0577f0eef21..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.sentry.systemtest.util - -import org.apache.http.impl.client.HttpClients -import org.springframework.http.client.BufferingClientHttpRequestFactory -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory -import org.springframework.web.client.RestTemplate - -open class LoggingInsecureRestClient { - - protected fun restTemplate(): RestTemplate { - val requestFactory = BufferingClientHttpRequestFactory( - HttpComponentsClientHttpRequestFactory(HttpClients.createDefault()) - ) - return RestTemplate(requestFactory).also { - it.messageConverters.add(0, jacksonConverter()) - } - } - - private fun jacksonConverter(): org.springframework.http.converter.json.MappingJackson2HttpMessageConverter { - val converter = org.springframework.http.converter.json.MappingJackson2HttpMessageConverter() - converter.objectMapper = objectMapper() - return converter - } - - private fun objectMapper(): com.fasterxml.jackson.databind.ObjectMapper { - val builder = org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.json() - val objectMapper: com.fasterxml.jackson.databind.ObjectMapper = builder.createXmlMapper(false).build() - objectMapper.registerModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) - objectMapper.configure(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) - return objectMapper - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt deleted file mode 100644 index f5d5bd7ee38..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.sentry.systemtest.util - -import io.sentry.samples.spring.boot.Person -import io.sentry.samples.spring.boot.Todo -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatus -import org.springframework.web.client.HttpStatusCodeException - -class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - var lastKnownStatusCode: HttpStatus? = null - - fun getPerson(id: Long): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/{id}", HttpMethod.GET, entityWithAuth(), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun createPerson(person: Person): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person), Person::class.java, person) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodo(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodoWebclient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-webclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders().also { - it.setBasicAuth("user", "password") - } - - return HttpEntity(request, headers) - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt deleted file mode 100644 index 7ef1699f122..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.util - -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod - -class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { - - fun getEnvelopeCount(): EnvelopeCounts { - val response = restTemplate().exchange("$baseUrl/envelope-count", HttpMethod.GET, entityWithAuth(), EnvelopeCounts::class.java) - return response.body!! - } - - fun reset() { - restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) - } - - fun getEnvelopes(): EnvelopesReceived { - val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) - return response.body!! - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders() - return HttpEntity(request, headers) - } -} - -class EnvelopeCounts { - val envelopes: Long? = null - - override fun toString(): String { - return "EnvelopeCounts{envelopes=$envelopes}" - } -} - -class EnvelopesReceived { - val envelopes: List? = null - - override fun toString(): String { - return "EnvelopesReceived{envelopes=$envelopes}" - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt deleted file mode 100644 index 1017d847348..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ /dev/null @@ -1,173 +0,0 @@ -package io.sentry.systemtest.util - -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Operation -import io.sentry.JsonSerializer -import io.sentry.SentryEvent -import io.sentry.SentryItemType -import io.sentry.SentryOptions -import io.sentry.protocol.SentrySpan -import io.sentry.protocol.SentryTransaction -import io.sentry.systemtest.graphql.GraphqlTestClient -import java.io.PrintWriter -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class TestHelper(backendUrl: String) { - - val restClient: RestTestClient - val graphqlClient: GraphqlTestClient - val sentryClient: SentryMockServerClient - val jsonSerializer: JsonSerializer - - var envelopeCounts: EnvelopeCounts? = null - - init { - restClient = RestTestClient(backendUrl) - sentryClient = SentryMockServerClient("http://localhost:8000") - graphqlClient = GraphqlTestClient(backendUrl) - jsonSerializer = JsonSerializer(SentryOptions.empty()) - } - - fun snapshotEnvelopeCount() { - envelopeCounts = sentryClient.getEnvelopeCount() - } - - fun ensureEnvelopeCountIncreased() { - Thread.sleep(1000) - val envelopeCountsAfter = sentryClient.getEnvelopeCount() - assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) - } - - fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { - Thread.sleep(10000) - val envelopes = sentryClient.getEnvelopes() - assertNotNull(envelopes.envelopes) - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - return - } - } - throw RuntimeException("Unable to find matching envelope received by relay") - } - - fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val transactionItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } - if (transactionItem == null) { - return@ensureEnvelopeReceived false - } - - val transaction = transactionItem.getTransaction(jsonSerializer) - if (transaction == null) { - return@ensureEnvelopeReceived false - } - - callback(transaction) - } - } - - fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val errorItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } - if (errorItem == null) { - return@ensureEnvelopeReceived false - } - - val error = errorItem.getEvent(jsonSerializer) - if (error == null) { - return@ensureEnvelopeReceived false - } - - val callbackResult = callback(error) - if (!callbackResult) { - println("found an error event but it did not match:") - logObject(error) - } - callbackResult - } - } - - fun ensureTransactionWithSpanReceived(callback: ((SentrySpan) -> Boolean)) { - ensureTransactionReceived { transaction -> - transaction.spans.forEach { span -> - val callbackResult = callback(span) - if (callbackResult) { - return@ensureTransactionReceived true - } - } - false - } - } - - fun reset() { - sentryClient.reset() - } - - fun logObject(obj: Any?) { - obj ?: return - PrintWriter(System.out).use { - jsonSerializer.serialize(obj, it) - } - } - - fun ensureNoErrors(response: ApolloResponse?) { - response ?: throw RuntimeException("no response") - assertFalse(response.hasErrors()) - } - - fun ensureErrorCount(response: ApolloResponse?, errorCount: Int) { - response ?: throw RuntimeException("no response") - assertEquals(errorCount, response.errors?.size) - } - - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.op == op } - if (span == null) { - println("Unable to find span with op $op in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.description == description } - if (span == null) { - println("Unable to find span with description $description in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { - val matches = transaction.contexts.trace?.operation == op - if (!matches) { - println("Unable to find transaction with op $op:") - logObject(transaction) - return false - } - - return true - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index be159d82445..5bf0d001881 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -7,7 +7,6 @@ plugins { id(Config.BuildPlugins.springDependencyManagement) version Config.BuildPlugins.springDependencyManagementVersion kotlin("jvm") kotlin("plugin.spring") version Config.kotlinVersion - id("com.apollographql.apollo3") version "3.8.2" } group = "io.sentry.sample.spring-boot" @@ -58,6 +57,8 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(Config.TestLibs.hsqldb) + + testImplementation(projects.sentrySystemTestSupport) testImplementation(Config.Libs.springBootStarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } @@ -124,13 +125,3 @@ tasks.named("test").configure { excludeTestsMatching("io.sentry.systemtest.*") } } - -apollo { - service("service") { - srcDir("src/test/graphql") - packageName.set("io.sentry.samples.graphql") - outputDirConnection { - connectToKotlinSourceSet("test") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/greeting.graphql b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/greeting.graphql deleted file mode 100644 index 06c866a65fa..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/greeting.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query GreetingQuery($name: String!) { - greeting(name: $name) -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/project.graphql b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/project.graphql deleted file mode 100644 index bff62ed2c2c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/project.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query ProjectQuery($slug: ID!) { - project(slug: $slug) { - slug - name - status - } -} - -mutation AddProjectMutation($slug: ID!) { - addProject(slug: $slug) -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/schema.graphqls deleted file mode 100644 index d76aca4756a..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/schema.graphqls +++ /dev/null @@ -1,70 +0,0 @@ -type Query { - greeting(name: String! = "Spring"): String! - project(slug: ID!): Project - tasks(projectSlug: ID!): [Task] -} - -type Mutation { - addProject(slug: ID!): String! -} - -type Subscription { - notifyNewTask(projectSlug: ID!): Task -} - -""" A Project in the Spring portfolio """ -type Project { - """ Unique string id used in URLs """ - slug: ID! - """ Project name """ - name: String - """ URL of the git repository """ - repositoryUrl: String! - """ Current support status """ - status: ProjectStatus! -} - -""" A task """ -type Task { - """ ID """ - id: String! - """ Name """ - name: String! - """ ID of the Assignee """ - assigneeId: String - """ Assignee """ - assignee: Assignee - """ ID of the Creator """ - creatorId: String - """ Creator """ - creator: Creator -} - -""" An Assignee """ -type Assignee { - """ ID """ - id: String! - """ Name """ - name: String! -} - -""" An Creator """ -type Creator { - """ ID """ - id: String! - """ Name """ - name: String! -} - -enum ProjectStatus { - """ Actively supported by the Spring team """ - ACTIVE - """ Supported by the community """ - COMMUNITY - """ Prototype, not officially supported yet """ - INCUBATING - """ Project being retired, in maintenance mode """ - ATTIC - """ End-Of-Lifed """ - EOL -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/task.graphql b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/task.graphql deleted file mode 100644 index 11ae18574d3..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/graphql/task.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query TasksAndAssigneesQuery($slug: ID!) { - tasks(projectSlug: $slug) { - id - name - assigneeId - assignee { - id - name - } - creatorId - creator { - id - name - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index b60f2b113a3..b4122d32311 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -19,7 +19,7 @@ class GraphqlGreetingSystemTest { val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "query GreetingQuery") } } @@ -32,7 +32,7 @@ class GraphqlGreetingSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "query GreetingQuery") } } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt index 8946284be43..6452bdaf1ca 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -23,7 +23,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "query ProjectQuery") } } @@ -34,7 +34,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertNotNull(response?.data?.addProject) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "mutation AddProjectMutation") } } @@ -48,7 +48,7 @@ class GraphqlProjectSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "mutation AddProjectMutation") } } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt index 2fba967c824..38343e5b0e5 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -30,7 +30,7 @@ class GraphqlTaskSystemTest { assertEquals("C3", firstTask.creatorId) assertEquals("C3", firstTask.creator?.id) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "query TasksAndAssigneesQuery") } } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index cfa459213c3..b0807f3fbc1 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,9 +1,7 @@ package io.sentry.systemtest -import io.sentry.samples.spring.boot.Person import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -21,9 +19,9 @@ class PersonSystemTest { fun `get person fails`() { val restClient = testHelper.restClient restClient.getPerson(1L) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") } @@ -34,12 +32,12 @@ class PersonSystemTest { val restClient = testHelper.restClient val person = Person("firstA", "lastB") val returnedPerson = restClient.createPerson(person) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") } @@ -56,12 +54,12 @@ class PersonSystemTest { "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-transaction=HTTP%20GET" ) ) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") } @@ -78,12 +76,12 @@ class PersonSystemTest { "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-transaction=HTTP%20GET" ) ) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index a48ea15fdd5..8b472ede78e 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -2,7 +2,6 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -20,9 +19,9 @@ class TodoSystemTest { fun `get todo works`() { val restClient = testHelper.restClient restClient.getTodo(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanSentryApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") @@ -33,9 +32,9 @@ class TodoSystemTest { fun `get todo webclient works`() { val restClient = testHelper.restClient restClient.getTodoWebclient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt deleted file mode 100644 index 0c11906292b..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.graphql - -import com.apollographql.apollo3.ApolloClient -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Mutation -import com.apollographql.apollo3.api.Query -import io.sentry.samples.graphql.AddProjectMutation -import io.sentry.samples.graphql.GreetingQuery -import io.sentry.samples.graphql.ProjectQuery -import io.sentry.samples.graphql.TasksAndAssigneesQuery -import kotlinx.coroutines.runBlocking - -class GraphqlTestClient(backendUrl: String) { - - val apollo = ApolloClient.Builder() - .serverUrl("$backendUrl/graphql") - .addHttpHeader("Authorization", "Basic dXNlcjpwYXNzd29yZA==") - .build() - - fun greet(name: String): ApolloResponse? { - return executeQuery(GreetingQuery(name)) - } - - fun project(slug: String): ApolloResponse? { - return executeQuery(ProjectQuery(slug)) - } - - fun tasksAndAssignees(slug: String): ApolloResponse? { - return executeQuery(TasksAndAssigneesQuery(slug)) - } - - fun addProject(slug: String): ApolloResponse? { - return executeMutation(AddProjectMutation(slug)) - } - - private fun executeQuery(query: Query): ApolloResponse? = runBlocking { - apollo.query(query).execute() - } - - private fun executeMutation(mutation: Mutation): ApolloResponse? = runBlocking { - apollo.mutation(mutation).execute() - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt deleted file mode 100644 index 0577f0eef21..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.sentry.systemtest.util - -import org.apache.http.impl.client.HttpClients -import org.springframework.http.client.BufferingClientHttpRequestFactory -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory -import org.springframework.web.client.RestTemplate - -open class LoggingInsecureRestClient { - - protected fun restTemplate(): RestTemplate { - val requestFactory = BufferingClientHttpRequestFactory( - HttpComponentsClientHttpRequestFactory(HttpClients.createDefault()) - ) - return RestTemplate(requestFactory).also { - it.messageConverters.add(0, jacksonConverter()) - } - } - - private fun jacksonConverter(): org.springframework.http.converter.json.MappingJackson2HttpMessageConverter { - val converter = org.springframework.http.converter.json.MappingJackson2HttpMessageConverter() - converter.objectMapper = objectMapper() - return converter - } - - private fun objectMapper(): com.fasterxml.jackson.databind.ObjectMapper { - val builder = org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.json() - val objectMapper: com.fasterxml.jackson.databind.ObjectMapper = builder.createXmlMapper(false).build() - objectMapper.registerModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) - objectMapper.configure(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) - return objectMapper - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt deleted file mode 100644 index 1c4eef63f78..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ /dev/null @@ -1,68 +0,0 @@ -package io.sentry.systemtest.util - -import io.sentry.samples.spring.boot.Person -import io.sentry.samples.spring.boot.Todo -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatus -import org.springframework.web.client.HttpStatusCodeException - -class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - var lastKnownStatusCode: HttpStatus? = null - - fun getPerson(id: Long): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/{id}", HttpMethod.GET, entityWithAuth(), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun createPerson(person: Person, extraHeaders: Map? = null): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person, extraHeaders), Person::class.java, person) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodo(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodoWebclient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-webclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - private fun entityWithAuth(request: Any? = null, extraHeaders: Map? = null): HttpEntity { - val headers = HttpHeaders().also { httpHeaders -> - httpHeaders.setBasicAuth("user", "password") - extraHeaders?.forEach { key, value -> - httpHeaders.set(key, value) - } - } - - return HttpEntity(request, headers) - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt deleted file mode 100644 index 7ef1699f122..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.util - -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod - -class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { - - fun getEnvelopeCount(): EnvelopeCounts { - val response = restTemplate().exchange("$baseUrl/envelope-count", HttpMethod.GET, entityWithAuth(), EnvelopeCounts::class.java) - return response.body!! - } - - fun reset() { - restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) - } - - fun getEnvelopes(): EnvelopesReceived { - val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) - return response.body!! - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders() - return HttpEntity(request, headers) - } -} - -class EnvelopeCounts { - val envelopes: Long? = null - - override fun toString(): String { - return "EnvelopeCounts{envelopes=$envelopes}" - } -} - -class EnvelopesReceived { - val envelopes: List? = null - - override fun toString(): String { - return "EnvelopesReceived{envelopes=$envelopes}" - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt deleted file mode 100644 index 1017d847348..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ /dev/null @@ -1,173 +0,0 @@ -package io.sentry.systemtest.util - -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Operation -import io.sentry.JsonSerializer -import io.sentry.SentryEvent -import io.sentry.SentryItemType -import io.sentry.SentryOptions -import io.sentry.protocol.SentrySpan -import io.sentry.protocol.SentryTransaction -import io.sentry.systemtest.graphql.GraphqlTestClient -import java.io.PrintWriter -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class TestHelper(backendUrl: String) { - - val restClient: RestTestClient - val graphqlClient: GraphqlTestClient - val sentryClient: SentryMockServerClient - val jsonSerializer: JsonSerializer - - var envelopeCounts: EnvelopeCounts? = null - - init { - restClient = RestTestClient(backendUrl) - sentryClient = SentryMockServerClient("http://localhost:8000") - graphqlClient = GraphqlTestClient(backendUrl) - jsonSerializer = JsonSerializer(SentryOptions.empty()) - } - - fun snapshotEnvelopeCount() { - envelopeCounts = sentryClient.getEnvelopeCount() - } - - fun ensureEnvelopeCountIncreased() { - Thread.sleep(1000) - val envelopeCountsAfter = sentryClient.getEnvelopeCount() - assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) - } - - fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { - Thread.sleep(10000) - val envelopes = sentryClient.getEnvelopes() - assertNotNull(envelopes.envelopes) - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - return - } - } - throw RuntimeException("Unable to find matching envelope received by relay") - } - - fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val transactionItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } - if (transactionItem == null) { - return@ensureEnvelopeReceived false - } - - val transaction = transactionItem.getTransaction(jsonSerializer) - if (transaction == null) { - return@ensureEnvelopeReceived false - } - - callback(transaction) - } - } - - fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val errorItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } - if (errorItem == null) { - return@ensureEnvelopeReceived false - } - - val error = errorItem.getEvent(jsonSerializer) - if (error == null) { - return@ensureEnvelopeReceived false - } - - val callbackResult = callback(error) - if (!callbackResult) { - println("found an error event but it did not match:") - logObject(error) - } - callbackResult - } - } - - fun ensureTransactionWithSpanReceived(callback: ((SentrySpan) -> Boolean)) { - ensureTransactionReceived { transaction -> - transaction.spans.forEach { span -> - val callbackResult = callback(span) - if (callbackResult) { - return@ensureTransactionReceived true - } - } - false - } - } - - fun reset() { - sentryClient.reset() - } - - fun logObject(obj: Any?) { - obj ?: return - PrintWriter(System.out).use { - jsonSerializer.serialize(obj, it) - } - } - - fun ensureNoErrors(response: ApolloResponse?) { - response ?: throw RuntimeException("no response") - assertFalse(response.hasErrors()) - } - - fun ensureErrorCount(response: ApolloResponse?, errorCount: Int) { - response ?: throw RuntimeException("no response") - assertEquals(errorCount, response.errors?.size) - } - - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.op == op } - if (span == null) { - println("Unable to find span with op $op in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.description == description } - if (span == null) { - println("Unable to find span with description $description in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { - val matches = transaction.contexts.trace?.operation == op - if (!matches) { - println("Unable to find transaction with op $op:") - logObject(transaction) - return false - } - - return true - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index fcc34c8b5b3..6d390ce15fa 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ plugins { id(Config.BuildPlugins.springDependencyManagement) version Config.BuildPlugins.springDependencyManagementVersion kotlin("jvm") kotlin("plugin.spring") version Config.kotlinVersion - id("com.apollographql.apollo3") version "3.8.2" } group = "io.sentry.sample.spring-boot-webflux-jakarta" @@ -30,6 +29,7 @@ dependencies { implementation(projects.sentryJdbc) implementation(projects.sentryGraphql22) + testImplementation(projects.sentrySystemTestSupport) testImplementation(Config.Libs.springBoot3StarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } @@ -77,13 +77,3 @@ tasks.named("test").configure { excludeTestsMatching("io.sentry.systemtest.*") } } - -apollo { - service("service") { - srcDir("src/test/graphql") - packageName.set("io.sentry.samples.graphql") - outputDirConnection { - connectToKotlinSourceSet("test") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/graphql/greeting.graphql b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/graphql/greeting.graphql deleted file mode 100644 index 06c866a65fa..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/graphql/greeting.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query GreetingQuery($name: String!) { - greeting(name: $name) -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/graphql/schema.graphqls deleted file mode 100644 index 111e0f2061c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/graphql/schema.graphqls +++ /dev/null @@ -1,3 +0,0 @@ -type Query { - greeting(name: String! = "Spring"): String! -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index 769ae399bf0..5681c421a28 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -19,7 +19,7 @@ class GraphqlGreetingSystemTest { val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } @@ -32,7 +32,7 @@ class GraphqlGreetingSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 4708d5609de..f4c3ad40bb1 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,9 +1,7 @@ package io.sentry.systemtest -import io.sentry.samples.spring.boot.jakarta.Person import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -21,9 +19,9 @@ class PersonSystemTest { fun `get person fails`() { val restClient = testHelper.restClient restClient.getPerson(1L) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } } @@ -33,12 +31,12 @@ class PersonSystemTest { val restClient = testHelper.restClient val person = Person("firstA", "lastB") val returnedPerson = restClient.createPerson(person) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index fe5c8252ed7..33b7cdeb135 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -2,7 +2,6 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -20,9 +19,9 @@ class TodoSystemTest { fun `get todo webclient works`() { val restClient = testHelper.restClient restClient.getTodoWebclient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt deleted file mode 100644 index e8301612aed..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.sentry.systemtest.graphql - -import com.apollographql.apollo3.ApolloClient -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Mutation -import com.apollographql.apollo3.api.Query -import io.sentry.samples.graphql.GreetingQuery -import kotlinx.coroutines.runBlocking - -class GraphqlTestClient(backendUrl: String) { - - val apollo = ApolloClient.Builder() - .serverUrl("$backendUrl/graphql") - .addHttpHeader("Authorization", "Basic dXNlcjpwYXNzd29yZA==") - .build() - - fun greet(name: String): ApolloResponse? { - return executeQuery(GreetingQuery(name)) - } - - private fun executeQuery(query: Query): ApolloResponse? = runBlocking { - apollo.query(query).execute() - } - - private fun executeMutation(mutation: Mutation): ApolloResponse? = runBlocking { - apollo.mutation(mutation).execute() - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt deleted file mode 100644 index 17eea1a0084..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.sentry.systemtest.util - -import org.springframework.http.client.BufferingClientHttpRequestFactory -import org.springframework.web.client.RestTemplate - -open class LoggingInsecureRestClient { - - protected fun restTemplate(): RestTemplate { - return RestTemplate().also { - it.requestFactory = BufferingClientHttpRequestFactory(it.requestFactory) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt deleted file mode 100644 index 9f77b962945..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.sentry.systemtest.util - -import io.sentry.samples.spring.boot.jakarta.Person -import io.sentry.samples.spring.boot.jakarta.Todo -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatusCode -import org.springframework.web.client.HttpStatusCodeException - -class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - var lastKnownStatusCode: HttpStatusCode? = null - - fun getPerson(id: Long): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/{id}", HttpMethod.GET, entityWithAuth(), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun createPerson(person: Person): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person), Person::class.java, person) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodo(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodoWebclient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-webclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders().also { - it.setBasicAuth("user", "password") - } - - return HttpEntity(request, headers) - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt deleted file mode 100644 index 7ef1699f122..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.util - -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod - -class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { - - fun getEnvelopeCount(): EnvelopeCounts { - val response = restTemplate().exchange("$baseUrl/envelope-count", HttpMethod.GET, entityWithAuth(), EnvelopeCounts::class.java) - return response.body!! - } - - fun reset() { - restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) - } - - fun getEnvelopes(): EnvelopesReceived { - val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) - return response.body!! - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders() - return HttpEntity(request, headers) - } -} - -class EnvelopeCounts { - val envelopes: Long? = null - - override fun toString(): String { - return "EnvelopeCounts{envelopes=$envelopes}" - } -} - -class EnvelopesReceived { - val envelopes: List? = null - - override fun toString(): String { - return "EnvelopesReceived{envelopes=$envelopes}" - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt deleted file mode 100644 index 1017d847348..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ /dev/null @@ -1,173 +0,0 @@ -package io.sentry.systemtest.util - -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Operation -import io.sentry.JsonSerializer -import io.sentry.SentryEvent -import io.sentry.SentryItemType -import io.sentry.SentryOptions -import io.sentry.protocol.SentrySpan -import io.sentry.protocol.SentryTransaction -import io.sentry.systemtest.graphql.GraphqlTestClient -import java.io.PrintWriter -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class TestHelper(backendUrl: String) { - - val restClient: RestTestClient - val graphqlClient: GraphqlTestClient - val sentryClient: SentryMockServerClient - val jsonSerializer: JsonSerializer - - var envelopeCounts: EnvelopeCounts? = null - - init { - restClient = RestTestClient(backendUrl) - sentryClient = SentryMockServerClient("http://localhost:8000") - graphqlClient = GraphqlTestClient(backendUrl) - jsonSerializer = JsonSerializer(SentryOptions.empty()) - } - - fun snapshotEnvelopeCount() { - envelopeCounts = sentryClient.getEnvelopeCount() - } - - fun ensureEnvelopeCountIncreased() { - Thread.sleep(1000) - val envelopeCountsAfter = sentryClient.getEnvelopeCount() - assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) - } - - fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { - Thread.sleep(10000) - val envelopes = sentryClient.getEnvelopes() - assertNotNull(envelopes.envelopes) - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - return - } - } - throw RuntimeException("Unable to find matching envelope received by relay") - } - - fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val transactionItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } - if (transactionItem == null) { - return@ensureEnvelopeReceived false - } - - val transaction = transactionItem.getTransaction(jsonSerializer) - if (transaction == null) { - return@ensureEnvelopeReceived false - } - - callback(transaction) - } - } - - fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val errorItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } - if (errorItem == null) { - return@ensureEnvelopeReceived false - } - - val error = errorItem.getEvent(jsonSerializer) - if (error == null) { - return@ensureEnvelopeReceived false - } - - val callbackResult = callback(error) - if (!callbackResult) { - println("found an error event but it did not match:") - logObject(error) - } - callbackResult - } - } - - fun ensureTransactionWithSpanReceived(callback: ((SentrySpan) -> Boolean)) { - ensureTransactionReceived { transaction -> - transaction.spans.forEach { span -> - val callbackResult = callback(span) - if (callbackResult) { - return@ensureTransactionReceived true - } - } - false - } - } - - fun reset() { - sentryClient.reset() - } - - fun logObject(obj: Any?) { - obj ?: return - PrintWriter(System.out).use { - jsonSerializer.serialize(obj, it) - } - } - - fun ensureNoErrors(response: ApolloResponse?) { - response ?: throw RuntimeException("no response") - assertFalse(response.hasErrors()) - } - - fun ensureErrorCount(response: ApolloResponse?, errorCount: Int) { - response ?: throw RuntimeException("no response") - assertEquals(errorCount, response.errors?.size) - } - - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.op == op } - if (span == null) { - println("Unable to find span with op $op in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.description == description } - if (span == null) { - println("Unable to find span with description $description in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { - val matches = transaction.contexts.trace?.operation == op - if (!matches) { - println("Unable to find transaction with op $op:") - logObject(transaction) - return false - } - - return true - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index b39f32514e7..3e50d013107 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -6,7 +6,6 @@ plugins { id(Config.BuildPlugins.springDependencyManagement) version Config.BuildPlugins.springDependencyManagementVersion kotlin("jvm") kotlin("plugin.spring") version Config.kotlinVersion - id("com.apollographql.apollo3") version "3.8.2" } group = "io.sentry.sample.spring-boot" @@ -27,6 +26,8 @@ dependencies { implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) implementation(projects.sentryGraphql) + + testImplementation(projects.sentrySystemTestSupport) testImplementation(Config.Libs.springBootStarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } @@ -75,13 +76,3 @@ tasks.named("test").configure { excludeTestsMatching("io.sentry.systemtest.*") } } - -apollo { - service("service") { - srcDir("src/test/graphql") - packageName.set("io.sentry.samples.graphql") - outputDirConnection { - connectToKotlinSourceSet("test") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/graphql/greeting.graphql b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/graphql/greeting.graphql deleted file mode 100644 index 06c866a65fa..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/graphql/greeting.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query GreetingQuery($name: String!) { - greeting(name: $name) -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/graphql/schema.graphqls deleted file mode 100644 index 111e0f2061c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/graphql/schema.graphqls +++ /dev/null @@ -1,3 +0,0 @@ -type Query { - greeting(name: String! = "Spring"): String! -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index 769ae399bf0..5681c421a28 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -19,7 +19,7 @@ class GraphqlGreetingSystemTest { val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } @@ -32,7 +32,7 @@ class GraphqlGreetingSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index e047431ee5b..f4c3ad40bb1 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,9 +1,7 @@ package io.sentry.systemtest -import io.sentry.samples.spring.boot.Person import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -21,9 +19,9 @@ class PersonSystemTest { fun `get person fails`() { val restClient = testHelper.restClient restClient.getPerson(1L) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } } @@ -33,12 +31,12 @@ class PersonSystemTest { val restClient = testHelper.restClient val person = Person("firstA", "lastB") val returnedPerson = restClient.createPerson(person) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index fe5c8252ed7..33b7cdeb135 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -2,7 +2,6 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -20,9 +19,9 @@ class TodoSystemTest { fun `get todo webclient works`() { val restClient = testHelper.restClient restClient.getTodoWebclient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt deleted file mode 100644 index e8301612aed..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.sentry.systemtest.graphql - -import com.apollographql.apollo3.ApolloClient -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Mutation -import com.apollographql.apollo3.api.Query -import io.sentry.samples.graphql.GreetingQuery -import kotlinx.coroutines.runBlocking - -class GraphqlTestClient(backendUrl: String) { - - val apollo = ApolloClient.Builder() - .serverUrl("$backendUrl/graphql") - .addHttpHeader("Authorization", "Basic dXNlcjpwYXNzd29yZA==") - .build() - - fun greet(name: String): ApolloResponse? { - return executeQuery(GreetingQuery(name)) - } - - private fun executeQuery(query: Query): ApolloResponse? = runBlocking { - apollo.query(query).execute() - } - - private fun executeMutation(mutation: Mutation): ApolloResponse? = runBlocking { - apollo.mutation(mutation).execute() - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt deleted file mode 100644 index 0577f0eef21..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.sentry.systemtest.util - -import org.apache.http.impl.client.HttpClients -import org.springframework.http.client.BufferingClientHttpRequestFactory -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory -import org.springframework.web.client.RestTemplate - -open class LoggingInsecureRestClient { - - protected fun restTemplate(): RestTemplate { - val requestFactory = BufferingClientHttpRequestFactory( - HttpComponentsClientHttpRequestFactory(HttpClients.createDefault()) - ) - return RestTemplate(requestFactory).also { - it.messageConverters.add(0, jacksonConverter()) - } - } - - private fun jacksonConverter(): org.springframework.http.converter.json.MappingJackson2HttpMessageConverter { - val converter = org.springframework.http.converter.json.MappingJackson2HttpMessageConverter() - converter.objectMapper = objectMapper() - return converter - } - - private fun objectMapper(): com.fasterxml.jackson.databind.ObjectMapper { - val builder = org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.json() - val objectMapper: com.fasterxml.jackson.databind.ObjectMapper = builder.createXmlMapper(false).build() - objectMapper.registerModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) - objectMapper.configure(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) - return objectMapper - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt deleted file mode 100644 index f5d5bd7ee38..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.sentry.systemtest.util - -import io.sentry.samples.spring.boot.Person -import io.sentry.samples.spring.boot.Todo -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatus -import org.springframework.web.client.HttpStatusCodeException - -class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - var lastKnownStatusCode: HttpStatus? = null - - fun getPerson(id: Long): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/{id}", HttpMethod.GET, entityWithAuth(), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun createPerson(person: Person): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person), Person::class.java, person) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodo(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodoWebclient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-webclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders().also { - it.setBasicAuth("user", "password") - } - - return HttpEntity(request, headers) - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt deleted file mode 100644 index 7ef1699f122..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.util - -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod - -class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { - - fun getEnvelopeCount(): EnvelopeCounts { - val response = restTemplate().exchange("$baseUrl/envelope-count", HttpMethod.GET, entityWithAuth(), EnvelopeCounts::class.java) - return response.body!! - } - - fun reset() { - restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) - } - - fun getEnvelopes(): EnvelopesReceived { - val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) - return response.body!! - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders() - return HttpEntity(request, headers) - } -} - -class EnvelopeCounts { - val envelopes: Long? = null - - override fun toString(): String { - return "EnvelopeCounts{envelopes=$envelopes}" - } -} - -class EnvelopesReceived { - val envelopes: List? = null - - override fun toString(): String { - return "EnvelopesReceived{envelopes=$envelopes}" - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt deleted file mode 100644 index 1017d847348..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ /dev/null @@ -1,173 +0,0 @@ -package io.sentry.systemtest.util - -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Operation -import io.sentry.JsonSerializer -import io.sentry.SentryEvent -import io.sentry.SentryItemType -import io.sentry.SentryOptions -import io.sentry.protocol.SentrySpan -import io.sentry.protocol.SentryTransaction -import io.sentry.systemtest.graphql.GraphqlTestClient -import java.io.PrintWriter -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class TestHelper(backendUrl: String) { - - val restClient: RestTestClient - val graphqlClient: GraphqlTestClient - val sentryClient: SentryMockServerClient - val jsonSerializer: JsonSerializer - - var envelopeCounts: EnvelopeCounts? = null - - init { - restClient = RestTestClient(backendUrl) - sentryClient = SentryMockServerClient("http://localhost:8000") - graphqlClient = GraphqlTestClient(backendUrl) - jsonSerializer = JsonSerializer(SentryOptions.empty()) - } - - fun snapshotEnvelopeCount() { - envelopeCounts = sentryClient.getEnvelopeCount() - } - - fun ensureEnvelopeCountIncreased() { - Thread.sleep(1000) - val envelopeCountsAfter = sentryClient.getEnvelopeCount() - assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) - } - - fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { - Thread.sleep(10000) - val envelopes = sentryClient.getEnvelopes() - assertNotNull(envelopes.envelopes) - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - return - } - } - throw RuntimeException("Unable to find matching envelope received by relay") - } - - fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val transactionItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } - if (transactionItem == null) { - return@ensureEnvelopeReceived false - } - - val transaction = transactionItem.getTransaction(jsonSerializer) - if (transaction == null) { - return@ensureEnvelopeReceived false - } - - callback(transaction) - } - } - - fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val errorItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } - if (errorItem == null) { - return@ensureEnvelopeReceived false - } - - val error = errorItem.getEvent(jsonSerializer) - if (error == null) { - return@ensureEnvelopeReceived false - } - - val callbackResult = callback(error) - if (!callbackResult) { - println("found an error event but it did not match:") - logObject(error) - } - callbackResult - } - } - - fun ensureTransactionWithSpanReceived(callback: ((SentrySpan) -> Boolean)) { - ensureTransactionReceived { transaction -> - transaction.spans.forEach { span -> - val callbackResult = callback(span) - if (callbackResult) { - return@ensureTransactionReceived true - } - } - false - } - } - - fun reset() { - sentryClient.reset() - } - - fun logObject(obj: Any?) { - obj ?: return - PrintWriter(System.out).use { - jsonSerializer.serialize(obj, it) - } - } - - fun ensureNoErrors(response: ApolloResponse?) { - response ?: throw RuntimeException("no response") - assertFalse(response.hasErrors()) - } - - fun ensureErrorCount(response: ApolloResponse?, errorCount: Int) { - response ?: throw RuntimeException("no response") - assertEquals(errorCount, response.errors?.size) - } - - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.op == op } - if (span == null) { - println("Unable to find span with op $op in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.description == description } - if (span == null) { - println("Unable to find span with description $description in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { - val matches = transaction.contexts.trace?.operation == op - if (!matches) { - println("Unable to find transaction with op $op:") - logObject(transaction) - return false - } - - return true - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 9c9f090885f..3ffdcc8bef0 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -6,7 +6,6 @@ plugins { id(Config.BuildPlugins.springDependencyManagement) version Config.BuildPlugins.springDependencyManagementVersion kotlin("jvm") kotlin("plugin.spring") version Config.kotlinVersion - id("com.apollographql.apollo3") version "3.8.2" } group = "io.sentry.sample.spring-boot" @@ -56,6 +55,7 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(Config.TestLibs.hsqldb) + testImplementation(projects.sentrySystemTestSupport) testImplementation(Config.Libs.springBootStarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } @@ -97,13 +97,3 @@ tasks.named("test").configure { excludeTestsMatching("io.sentry.systemtest.*") } } - -apollo { - service("service") { - srcDir("src/test/graphql") - packageName.set("io.sentry.samples.graphql") - outputDirConnection { - connectToKotlinSourceSet("test") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/graphql/greeting.graphql b/sentry-samples/sentry-samples-spring-boot/src/test/graphql/greeting.graphql deleted file mode 100644 index 06c866a65fa..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/graphql/greeting.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query GreetingQuery($name: String!) { - greeting(name: $name) -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/graphql/project.graphql b/sentry-samples/sentry-samples-spring-boot/src/test/graphql/project.graphql deleted file mode 100644 index bff62ed2c2c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/graphql/project.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query ProjectQuery($slug: ID!) { - project(slug: $slug) { - slug - name - status - } -} - -mutation AddProjectMutation($slug: ID!) { - addProject(slug: $slug) -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot/src/test/graphql/schema.graphqls deleted file mode 100644 index d76aca4756a..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/graphql/schema.graphqls +++ /dev/null @@ -1,70 +0,0 @@ -type Query { - greeting(name: String! = "Spring"): String! - project(slug: ID!): Project - tasks(projectSlug: ID!): [Task] -} - -type Mutation { - addProject(slug: ID!): String! -} - -type Subscription { - notifyNewTask(projectSlug: ID!): Task -} - -""" A Project in the Spring portfolio """ -type Project { - """ Unique string id used in URLs """ - slug: ID! - """ Project name """ - name: String - """ URL of the git repository """ - repositoryUrl: String! - """ Current support status """ - status: ProjectStatus! -} - -""" A task """ -type Task { - """ ID """ - id: String! - """ Name """ - name: String! - """ ID of the Assignee """ - assigneeId: String - """ Assignee """ - assignee: Assignee - """ ID of the Creator """ - creatorId: String - """ Creator """ - creator: Creator -} - -""" An Assignee """ -type Assignee { - """ ID """ - id: String! - """ Name """ - name: String! -} - -""" An Creator """ -type Creator { - """ ID """ - id: String! - """ Name """ - name: String! -} - -enum ProjectStatus { - """ Actively supported by the Spring team """ - ACTIVE - """ Supported by the community """ - COMMUNITY - """ Prototype, not officially supported yet """ - INCUBATING - """ Project being retired, in maintenance mode """ - ATTIC - """ End-Of-Lifed """ - EOL -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/graphql/task.graphql b/sentry-samples/sentry-samples-spring-boot/src/test/graphql/task.graphql deleted file mode 100644 index 11ae18574d3..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/graphql/task.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query TasksAndAssigneesQuery($slug: ID!) { - tasks(projectSlug: $slug) { - id - name - assigneeId - assignee { - id - name - } - creatorId - creator { - id - name - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index 769ae399bf0..5681c421a28 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -19,7 +19,7 @@ class GraphqlGreetingSystemTest { val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } @@ -32,7 +32,7 @@ class GraphqlGreetingSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt index 74b196e33b6..bfa38fead33 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -23,7 +23,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.project") } } @@ -34,7 +34,7 @@ class GraphqlProjectSystemTest { testHelper.ensureNoErrors(response) assertNotNull(response?.data?.addProject) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") } } @@ -48,7 +48,7 @@ class GraphqlProjectSystemTest { testHelper.ensureErrorReceived { error -> error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false } - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt index cf2aebfd090..0f634f309bd 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -30,7 +30,7 @@ class GraphqlTaskSystemTest { assertEquals("C3", firstTask.creatorId) assertEquals("C3", firstTask.creator?.id) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.tasks") && testHelper.doesTransactionContainSpanWithDescription(transaction, "Task.assignee") && testHelper.doesTransactionContainSpanWithDescription(transaction, "Task.creator") diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 96b0860856a..c190b86d6b0 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,9 +1,7 @@ package io.sentry.systemtest -import io.sentry.samples.spring.boot.Person import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -21,9 +19,9 @@ class PersonSystemTest { fun `get person fails`() { val restClient = testHelper.restClient restClient.getPerson(1L) - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + assertEquals(500, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } } @@ -33,12 +31,12 @@ class PersonSystemTest { val restClient = testHelper.restClient val person = Person("firstA", "lastB") val returnedPerson = restClient.createPerson(person) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && testHelper.doesTransactionContainSpanWithOp(transaction, "db.query") } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index 7a756d98da0..02afc3e0ad6 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -2,7 +2,6 @@ package io.sentry.systemtest import io.sentry.systemtest.util.TestHelper import org.junit.Before -import org.springframework.http.HttpStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -20,9 +19,9 @@ class TodoSystemTest { fun `get todo works`() { val restClient = testHelper.restClient restClient.getTodo(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } @@ -31,9 +30,9 @@ class TodoSystemTest { fun `get todo webclient works`() { val restClient = testHelper.restClient restClient.getTodoWebclient(1L) - assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + assertEquals(200, restClient.lastKnownStatusCode) - testHelper.ensureTransactionReceived { transaction -> + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt deleted file mode 100644 index 0c11906292b..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.graphql - -import com.apollographql.apollo3.ApolloClient -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Mutation -import com.apollographql.apollo3.api.Query -import io.sentry.samples.graphql.AddProjectMutation -import io.sentry.samples.graphql.GreetingQuery -import io.sentry.samples.graphql.ProjectQuery -import io.sentry.samples.graphql.TasksAndAssigneesQuery -import kotlinx.coroutines.runBlocking - -class GraphqlTestClient(backendUrl: String) { - - val apollo = ApolloClient.Builder() - .serverUrl("$backendUrl/graphql") - .addHttpHeader("Authorization", "Basic dXNlcjpwYXNzd29yZA==") - .build() - - fun greet(name: String): ApolloResponse? { - return executeQuery(GreetingQuery(name)) - } - - fun project(slug: String): ApolloResponse? { - return executeQuery(ProjectQuery(slug)) - } - - fun tasksAndAssignees(slug: String): ApolloResponse? { - return executeQuery(TasksAndAssigneesQuery(slug)) - } - - fun addProject(slug: String): ApolloResponse? { - return executeMutation(AddProjectMutation(slug)) - } - - private fun executeQuery(query: Query): ApolloResponse? = runBlocking { - apollo.query(query).execute() - } - - private fun executeMutation(mutation: Mutation): ApolloResponse? = runBlocking { - apollo.mutation(mutation).execute() - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt deleted file mode 100644 index 0577f0eef21..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.sentry.systemtest.util - -import org.apache.http.impl.client.HttpClients -import org.springframework.http.client.BufferingClientHttpRequestFactory -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory -import org.springframework.web.client.RestTemplate - -open class LoggingInsecureRestClient { - - protected fun restTemplate(): RestTemplate { - val requestFactory = BufferingClientHttpRequestFactory( - HttpComponentsClientHttpRequestFactory(HttpClients.createDefault()) - ) - return RestTemplate(requestFactory).also { - it.messageConverters.add(0, jacksonConverter()) - } - } - - private fun jacksonConverter(): org.springframework.http.converter.json.MappingJackson2HttpMessageConverter { - val converter = org.springframework.http.converter.json.MappingJackson2HttpMessageConverter() - converter.objectMapper = objectMapper() - return converter - } - - private fun objectMapper(): com.fasterxml.jackson.databind.ObjectMapper { - val builder = org.springframework.http.converter.json.Jackson2ObjectMapperBuilder.json() - val objectMapper: com.fasterxml.jackson.databind.ObjectMapper = builder.createXmlMapper(false).build() - objectMapper.registerModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) - objectMapper.configure(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) - return objectMapper - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt deleted file mode 100644 index f5d5bd7ee38..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.sentry.systemtest.util - -import io.sentry.samples.spring.boot.Person -import io.sentry.samples.spring.boot.Todo -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatus -import org.springframework.web.client.HttpStatusCodeException - -class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - var lastKnownStatusCode: HttpStatus? = null - - fun getPerson(id: Long): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/{id}", HttpMethod.GET, entityWithAuth(), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun createPerson(person: Person): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person), Person::class.java, person) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodo(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - fun getTodoWebclient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-webclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode - null - } - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders().also { - it.setBasicAuth("user", "password") - } - - return HttpEntity(request, headers) - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt deleted file mode 100644 index 7ef1699f122..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.sentry.systemtest.util - -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod - -class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { - - fun getEnvelopeCount(): EnvelopeCounts { - val response = restTemplate().exchange("$baseUrl/envelope-count", HttpMethod.GET, entityWithAuth(), EnvelopeCounts::class.java) - return response.body!! - } - - fun reset() { - restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) - } - - fun getEnvelopes(): EnvelopesReceived { - val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) - return response.body!! - } - - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders() - return HttpEntity(request, headers) - } -} - -class EnvelopeCounts { - val envelopes: Long? = null - - override fun toString(): String { - return "EnvelopeCounts{envelopes=$envelopes}" - } -} - -class EnvelopesReceived { - val envelopes: List? = null - - override fun toString(): String { - return "EnvelopesReceived{envelopes=$envelopes}" - } -} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt deleted file mode 100644 index 1017d847348..00000000000 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ /dev/null @@ -1,173 +0,0 @@ -package io.sentry.systemtest.util - -import com.apollographql.apollo3.api.ApolloResponse -import com.apollographql.apollo3.api.Operation -import io.sentry.JsonSerializer -import io.sentry.SentryEvent -import io.sentry.SentryItemType -import io.sentry.SentryOptions -import io.sentry.protocol.SentrySpan -import io.sentry.protocol.SentryTransaction -import io.sentry.systemtest.graphql.GraphqlTestClient -import java.io.PrintWriter -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class TestHelper(backendUrl: String) { - - val restClient: RestTestClient - val graphqlClient: GraphqlTestClient - val sentryClient: SentryMockServerClient - val jsonSerializer: JsonSerializer - - var envelopeCounts: EnvelopeCounts? = null - - init { - restClient = RestTestClient(backendUrl) - sentryClient = SentryMockServerClient("http://localhost:8000") - graphqlClient = GraphqlTestClient(backendUrl) - jsonSerializer = JsonSerializer(SentryOptions.empty()) - } - - fun snapshotEnvelopeCount() { - envelopeCounts = sentryClient.getEnvelopeCount() - } - - fun ensureEnvelopeCountIncreased() { - Thread.sleep(1000) - val envelopeCountsAfter = sentryClient.getEnvelopeCount() - assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) - } - - fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { - Thread.sleep(10000) - val envelopes = sentryClient.getEnvelopes() - assertNotNull(envelopes.envelopes) - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - return - } - } - throw RuntimeException("Unable to find matching envelope received by relay") - } - - fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val transactionItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } - if (transactionItem == null) { - return@ensureEnvelopeReceived false - } - - val transaction = transactionItem.getTransaction(jsonSerializer) - if (transaction == null) { - return@ensureEnvelopeReceived false - } - - callback(transaction) - } - } - - fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> - val deserializeEnvelope = - jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val errorItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } - if (errorItem == null) { - return@ensureEnvelopeReceived false - } - - val error = errorItem.getEvent(jsonSerializer) - if (error == null) { - return@ensureEnvelopeReceived false - } - - val callbackResult = callback(error) - if (!callbackResult) { - println("found an error event but it did not match:") - logObject(error) - } - callbackResult - } - } - - fun ensureTransactionWithSpanReceived(callback: ((SentrySpan) -> Boolean)) { - ensureTransactionReceived { transaction -> - transaction.spans.forEach { span -> - val callbackResult = callback(span) - if (callbackResult) { - return@ensureTransactionReceived true - } - } - false - } - } - - fun reset() { - sentryClient.reset() - } - - fun logObject(obj: Any?) { - obj ?: return - PrintWriter(System.out).use { - jsonSerializer.serialize(obj, it) - } - } - - fun ensureNoErrors(response: ApolloResponse?) { - response ?: throw RuntimeException("no response") - assertFalse(response.hasErrors()) - } - - fun ensureErrorCount(response: ApolloResponse?, errorCount: Int) { - response ?: throw RuntimeException("no response") - assertEquals(errorCount, response.errors?.size) - } - - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.op == op } - if (span == null) { - println("Unable to find span with op $op in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.description == description } - if (span == null) { - println("Unable to find span with description $description in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { - val matches = transaction.contexts.trace?.operation == op - if (!matches) { - println("Unable to find transaction with op $op:") - logObject(transaction) - return false - } - - return true - } -} diff --git a/sentry-system-test-support/api/sentry-system-test-support.api b/sentry-system-test-support/api/sentry-system-test-support.api new file mode 100644 index 00000000000..f91725edfbf --- /dev/null +++ b/sentry-system-test-support/api/sentry-system-test-support.api @@ -0,0 +1,572 @@ +public final class io/sentry/samples/graphql/AddProjectMutation : com/apollographql/apollo3/api/Mutation { + public static final field Companion Lio/sentry/samples/graphql/AddProjectMutation$Companion; + public static final field OPERATION_ID Ljava/lang/String; + public static final field OPERATION_NAME Ljava/lang/String; + public fun (Ljava/lang/String;)V + public fun adapter ()Lcom/apollographql/apollo3/api/Adapter; + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/sentry/samples/graphql/AddProjectMutation; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/AddProjectMutation;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/samples/graphql/AddProjectMutation; + public fun document ()Ljava/lang/String; + public fun equals (Ljava/lang/Object;)Z + public final fun getSlug ()Ljava/lang/String; + public fun hashCode ()I + public fun id ()Ljava/lang/String; + public fun name ()Ljava/lang/String; + public fun rootField ()Lcom/apollographql/apollo3/api/CompiledField; + public fun serializeVariables (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)V + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/AddProjectMutation$Companion { + public final fun getOPERATION_DOCUMENT ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/AddProjectMutation$Data : com/apollographql/apollo3/api/Mutation$Data { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/sentry/samples/graphql/AddProjectMutation$Data; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/AddProjectMutation$Data;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/samples/graphql/AddProjectMutation$Data; + public fun equals (Ljava/lang/Object;)Z + public final fun getAddProject ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/GreetingQuery : com/apollographql/apollo3/api/Query { + public static final field Companion Lio/sentry/samples/graphql/GreetingQuery$Companion; + public static final field OPERATION_ID Ljava/lang/String; + public static final field OPERATION_NAME Ljava/lang/String; + public fun (Ljava/lang/String;)V + public fun adapter ()Lcom/apollographql/apollo3/api/Adapter; + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/sentry/samples/graphql/GreetingQuery; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/GreetingQuery;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/samples/graphql/GreetingQuery; + public fun document ()Ljava/lang/String; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun id ()Ljava/lang/String; + public fun name ()Ljava/lang/String; + public fun rootField ()Lcom/apollographql/apollo3/api/CompiledField; + public fun serializeVariables (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)V + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/GreetingQuery$Companion { + public final fun getOPERATION_DOCUMENT ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/GreetingQuery$Data : com/apollographql/apollo3/api/Query$Data { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/sentry/samples/graphql/GreetingQuery$Data; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/GreetingQuery$Data;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/samples/graphql/GreetingQuery$Data; + public fun equals (Ljava/lang/Object;)Z + public final fun getGreeting ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/ProjectQuery : com/apollographql/apollo3/api/Query { + public static final field Companion Lio/sentry/samples/graphql/ProjectQuery$Companion; + public static final field OPERATION_ID Ljava/lang/String; + public static final field OPERATION_NAME Ljava/lang/String; + public fun (Ljava/lang/String;)V + public fun adapter ()Lcom/apollographql/apollo3/api/Adapter; + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/sentry/samples/graphql/ProjectQuery; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/ProjectQuery;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/samples/graphql/ProjectQuery; + public fun document ()Ljava/lang/String; + public fun equals (Ljava/lang/Object;)Z + public final fun getSlug ()Ljava/lang/String; + public fun hashCode ()I + public fun id ()Ljava/lang/String; + public fun name ()Ljava/lang/String; + public fun rootField ()Lcom/apollographql/apollo3/api/CompiledField; + public fun serializeVariables (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)V + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/ProjectQuery$Companion { + public final fun getOPERATION_DOCUMENT ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/ProjectQuery$Data : com/apollographql/apollo3/api/Query$Data { + public fun (Lio/sentry/samples/graphql/ProjectQuery$Project;)V + public final fun component1 ()Lio/sentry/samples/graphql/ProjectQuery$Project; + public final fun copy (Lio/sentry/samples/graphql/ProjectQuery$Project;)Lio/sentry/samples/graphql/ProjectQuery$Data; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/ProjectQuery$Data;Lio/sentry/samples/graphql/ProjectQuery$Project;ILjava/lang/Object;)Lio/sentry/samples/graphql/ProjectQuery$Data; + public fun equals (Ljava/lang/Object;)Z + public final fun getProject ()Lio/sentry/samples/graphql/ProjectQuery$Project; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/ProjectQuery$Project { + public fun (Ljava/lang/String;Ljava/lang/String;Lio/sentry/samples/graphql/type/ProjectStatus;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lio/sentry/samples/graphql/type/ProjectStatus; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Lio/sentry/samples/graphql/type/ProjectStatus;)Lio/sentry/samples/graphql/ProjectQuery$Project; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/ProjectQuery$Project;Ljava/lang/String;Ljava/lang/String;Lio/sentry/samples/graphql/type/ProjectStatus;ILjava/lang/Object;)Lio/sentry/samples/graphql/ProjectQuery$Project; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getSlug ()Ljava/lang/String; + public final fun getStatus ()Lio/sentry/samples/graphql/type/ProjectStatus; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/TasksAndAssigneesQuery : com/apollographql/apollo3/api/Query { + public static final field Companion Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Companion; + public static final field OPERATION_ID Ljava/lang/String; + public static final field OPERATION_NAME Ljava/lang/String; + public fun (Ljava/lang/String;)V + public fun adapter ()Lcom/apollographql/apollo3/api/Adapter; + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/TasksAndAssigneesQuery;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery; + public fun document ()Ljava/lang/String; + public fun equals (Ljava/lang/Object;)Z + public final fun getSlug ()Ljava/lang/String; + public fun hashCode ()I + public fun id ()Ljava/lang/String; + public fun name ()Ljava/lang/String; + public fun rootField ()Lcom/apollographql/apollo3/api/CompiledField; + public fun serializeVariables (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)V + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/TasksAndAssigneesQuery$Companion { + public final fun getOPERATION_DOCUMENT ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/TasksAndAssigneesQuery$Creator { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Creator; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Creator;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Creator; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/TasksAndAssigneesQuery$Data : com/apollographql/apollo3/api/Query$Data { + public fun (Ljava/util/List;)V + public final fun component1 ()Ljava/util/List; + public final fun copy (Ljava/util/List;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Data; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Data;Ljava/util/List;ILjava/lang/Object;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Data; + public fun equals (Ljava/lang/Object;)Z + public final fun getTasks ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/TasksAndAssigneesQuery$Task { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee;Ljava/lang/String;Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Creator;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Creator; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee;Ljava/lang/String;Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Creator;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Task; + public static synthetic fun copy$default (Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Task;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee;Ljava/lang/String;Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Creator;ILjava/lang/Object;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Task; + public fun equals (Ljava/lang/Object;)Z + public final fun getAssignee ()Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee; + public final fun getAssigneeId ()Ljava/lang/String; + public final fun getCreator ()Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Creator; + public final fun getCreatorId ()Ljava/lang/String; + public final fun getId ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/samples/graphql/adapter/AddProjectMutation_ResponseAdapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/AddProjectMutation_ResponseAdapter; +} + +public final class io/sentry/samples/graphql/adapter/AddProjectMutation_ResponseAdapter$Data : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/AddProjectMutation_ResponseAdapter$Data; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/AddProjectMutation$Data; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public final fun getRESPONSE_NAMES ()Ljava/util/List; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/AddProjectMutation$Data;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/AddProjectMutation_VariablesAdapter : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/AddProjectMutation_VariablesAdapter; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/AddProjectMutation; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/AddProjectMutation;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/GreetingQuery_ResponseAdapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/GreetingQuery_ResponseAdapter; +} + +public final class io/sentry/samples/graphql/adapter/GreetingQuery_ResponseAdapter$Data : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/GreetingQuery_ResponseAdapter$Data; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/GreetingQuery$Data; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public final fun getRESPONSE_NAMES ()Ljava/util/List; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/GreetingQuery$Data;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/GreetingQuery_VariablesAdapter : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/GreetingQuery_VariablesAdapter; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/GreetingQuery; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/GreetingQuery;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/ProjectQuery_ResponseAdapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/ProjectQuery_ResponseAdapter; +} + +public final class io/sentry/samples/graphql/adapter/ProjectQuery_ResponseAdapter$Data : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/ProjectQuery_ResponseAdapter$Data; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/ProjectQuery$Data; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public final fun getRESPONSE_NAMES ()Ljava/util/List; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/ProjectQuery$Data;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/ProjectQuery_ResponseAdapter$Project : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/ProjectQuery_ResponseAdapter$Project; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/ProjectQuery$Project; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public final fun getRESPONSE_NAMES ()Ljava/util/List; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/ProjectQuery$Project;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/ProjectQuery_VariablesAdapter : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/ProjectQuery_VariablesAdapter; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/ProjectQuery; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/ProjectQuery;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_ResponseAdapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_ResponseAdapter; +} + +public final class io/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_ResponseAdapter$Assignee : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_ResponseAdapter$Assignee; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public final fun getRESPONSE_NAMES ()Ljava/util/List; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Assignee;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_ResponseAdapter$Creator : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_ResponseAdapter$Creator; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Creator; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public final fun getRESPONSE_NAMES ()Ljava/util/List; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Creator;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_ResponseAdapter$Data : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_ResponseAdapter$Data; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Data; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public final fun getRESPONSE_NAMES ()Ljava/util/List; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Data;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_ResponseAdapter$Task : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_ResponseAdapter$Task; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Task; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public final fun getRESPONSE_NAMES ()Ljava/util/List; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/TasksAndAssigneesQuery$Task;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_VariablesAdapter : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/adapter/TasksAndAssigneesQuery_VariablesAdapter; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/TasksAndAssigneesQuery; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/TasksAndAssigneesQuery;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/samples/graphql/selections/AddProjectMutationSelections { + public static final field INSTANCE Lio/sentry/samples/graphql/selections/AddProjectMutationSelections; + public final fun get__root ()Ljava/util/List; +} + +public final class io/sentry/samples/graphql/selections/GreetingQuerySelections { + public static final field INSTANCE Lio/sentry/samples/graphql/selections/GreetingQuerySelections; + public final fun get__root ()Ljava/util/List; +} + +public final class io/sentry/samples/graphql/selections/ProjectQuerySelections { + public static final field INSTANCE Lio/sentry/samples/graphql/selections/ProjectQuerySelections; + public final fun get__root ()Ljava/util/List; +} + +public final class io/sentry/samples/graphql/selections/TasksAndAssigneesQuerySelections { + public static final field INSTANCE Lio/sentry/samples/graphql/selections/TasksAndAssigneesQuerySelections; + public final fun get__root ()Ljava/util/List; +} + +public final class io/sentry/samples/graphql/type/Assignee { + public static final field Companion Lio/sentry/samples/graphql/type/Assignee$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/Assignee$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/ObjectType; +} + +public final class io/sentry/samples/graphql/type/Creator { + public static final field Companion Lio/sentry/samples/graphql/type/Creator$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/Creator$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/ObjectType; +} + +public final class io/sentry/samples/graphql/type/GraphQLBoolean { + public static final field Companion Lio/sentry/samples/graphql/type/GraphQLBoolean$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/GraphQLBoolean$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/CustomScalarType; +} + +public final class io/sentry/samples/graphql/type/GraphQLFloat { + public static final field Companion Lio/sentry/samples/graphql/type/GraphQLFloat$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/GraphQLFloat$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/CustomScalarType; +} + +public final class io/sentry/samples/graphql/type/GraphQLID { + public static final field Companion Lio/sentry/samples/graphql/type/GraphQLID$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/GraphQLID$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/CustomScalarType; +} + +public final class io/sentry/samples/graphql/type/GraphQLInt { + public static final field Companion Lio/sentry/samples/graphql/type/GraphQLInt$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/GraphQLInt$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/CustomScalarType; +} + +public final class io/sentry/samples/graphql/type/GraphQLString { + public static final field Companion Lio/sentry/samples/graphql/type/GraphQLString$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/GraphQLString$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/CustomScalarType; +} + +public final class io/sentry/samples/graphql/type/Mutation { + public static final field Companion Lio/sentry/samples/graphql/type/Mutation$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/Mutation$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/ObjectType; +} + +public final class io/sentry/samples/graphql/type/Project { + public static final field Companion Lio/sentry/samples/graphql/type/Project$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/Project$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/ObjectType; +} + +public final class io/sentry/samples/graphql/type/ProjectStatus : java/lang/Enum { + public static final field ACTIVE Lio/sentry/samples/graphql/type/ProjectStatus; + public static final field ATTIC Lio/sentry/samples/graphql/type/ProjectStatus; + public static final field COMMUNITY Lio/sentry/samples/graphql/type/ProjectStatus; + public static final field Companion Lio/sentry/samples/graphql/type/ProjectStatus$Companion; + public static final field EOL Lio/sentry/samples/graphql/type/ProjectStatus; + public static final field INCUBATING Lio/sentry/samples/graphql/type/ProjectStatus; + public static final field UNKNOWN__ Lio/sentry/samples/graphql/type/ProjectStatus; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getRawValue ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/samples/graphql/type/ProjectStatus; + public static fun values ()[Lio/sentry/samples/graphql/type/ProjectStatus; +} + +public final class io/sentry/samples/graphql/type/ProjectStatus$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/EnumType; + public final fun knownValues ()[Lio/sentry/samples/graphql/type/ProjectStatus; + public final fun safeValueOf (Ljava/lang/String;)Lio/sentry/samples/graphql/type/ProjectStatus; +} + +public final class io/sentry/samples/graphql/type/Query { + public static final field Companion Lio/sentry/samples/graphql/type/Query$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/Query$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/ObjectType; +} + +public final class io/sentry/samples/graphql/type/Task { + public static final field Companion Lio/sentry/samples/graphql/type/Task$Companion; + public fun ()V +} + +public final class io/sentry/samples/graphql/type/Task$Companion { + public final fun getType ()Lcom/apollographql/apollo3/api/ObjectType; +} + +public final class io/sentry/samples/graphql/type/adapter/ProjectStatus_ResponseAdapter : com/apollographql/apollo3/api/Adapter { + public static final field INSTANCE Lio/sentry/samples/graphql/type/adapter/ProjectStatus_ResponseAdapter; + public fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Lio/sentry/samples/graphql/type/ProjectStatus; + public synthetic fun fromJson (Lcom/apollographql/apollo3/api/json/JsonReader;Lcom/apollographql/apollo3/api/CustomScalarAdapters;)Ljava/lang/Object; + public fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Lio/sentry/samples/graphql/type/ProjectStatus;)V + public synthetic fun toJson (Lcom/apollographql/apollo3/api/json/JsonWriter;Lcom/apollographql/apollo3/api/CustomScalarAdapters;Ljava/lang/Object;)V +} + +public final class io/sentry/systemtest/Person { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/systemtest/Person; + public static synthetic fun copy$default (Lio/sentry/systemtest/Person;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/systemtest/Person; + public fun equals (Ljava/lang/Object;)Z + public final fun getFirstName ()Ljava/lang/String; + public final fun getLastName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/systemtest/Todo { + public fun (JLjava/lang/String;Z)V + public final fun component1 ()J + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Z + public final fun copy (JLjava/lang/String;Z)Lio/sentry/systemtest/Todo; + public static synthetic fun copy$default (Lio/sentry/systemtest/Todo;JLjava/lang/String;ZILjava/lang/Object;)Lio/sentry/systemtest/Todo; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()J + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public final fun isCompleted ()Z + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/systemtest/graphql/GraphqlTestClient { + public fun (Ljava/lang/String;)V + public final fun addProject (Ljava/lang/String;)Lcom/apollographql/apollo3/api/ApolloResponse; + public final fun getApollo ()Lcom/apollographql/apollo3/ApolloClient; + public final fun greet (Ljava/lang/String;)Lcom/apollographql/apollo3/api/ApolloResponse; + public final fun project (Ljava/lang/String;)Lcom/apollographql/apollo3/api/ApolloResponse; + public final fun tasksAndAssignees (Ljava/lang/String;)Lcom/apollographql/apollo3/api/ApolloResponse; +} + +public final class io/sentry/systemtest/util/EnvelopeCounts { + public fun ()V + public final fun getEnvelopes ()Ljava/lang/Long; + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/systemtest/util/EnvelopesReceived { + public fun ()V + public final fun getEnvelopes ()Ljava/util/List; + public fun toString ()Ljava/lang/String; +} + +public class io/sentry/systemtest/util/LoggingInsecureRestClient { + public fun ()V + protected final fun restTemplate ()Lorg/springframework/web/client/RestTemplate; +} + +public final class io/sentry/systemtest/util/RestTestClient : io/sentry/systemtest/util/LoggingInsecureRestClient { + public fun (Ljava/lang/String;)V + public final fun createPerson (Lio/sentry/systemtest/Person;Ljava/util/Map;)Lio/sentry/systemtest/Person; + public static synthetic fun createPerson$default (Lio/sentry/systemtest/util/RestTestClient;Lio/sentry/systemtest/Person;Ljava/util/Map;ILjava/lang/Object;)Lio/sentry/systemtest/Person; + public final fun createPersonDistributedTracing (Lio/sentry/systemtest/Person;Ljava/util/Map;)Lio/sentry/systemtest/Person; + public static synthetic fun createPersonDistributedTracing$default (Lio/sentry/systemtest/util/RestTestClient;Lio/sentry/systemtest/Person;Ljava/util/Map;ILjava/lang/Object;)Lio/sentry/systemtest/Person; + public final fun getLastKnownStatusCode ()Ljava/lang/Integer; + public final fun getPerson (J)Lio/sentry/systemtest/Person; + public final fun getPersonDistributedTracing (JLjava/util/Map;)Lio/sentry/systemtest/Person; + public static synthetic fun getPersonDistributedTracing$default (Lio/sentry/systemtest/util/RestTestClient;JLjava/util/Map;ILjava/lang/Object;)Lio/sentry/systemtest/Person; + public final fun getTodo (J)Lio/sentry/systemtest/Todo; + public final fun getTodoRestClient (J)Lio/sentry/systemtest/Todo; + public final fun getTodoWebclient (J)Lio/sentry/systemtest/Todo; + public final fun setLastKnownStatusCode (Ljava/lang/Integer;)V +} + +public final class io/sentry/systemtest/util/SentryMockServerClient : io/sentry/systemtest/util/LoggingInsecureRestClient { + public fun (Ljava/lang/String;)V + public final fun getEnvelopeCount ()Lio/sentry/systemtest/util/EnvelopeCounts; + public final fun getEnvelopes ()Lio/sentry/systemtest/util/EnvelopesReceived; + public final fun reset ()V +} + +public final class io/sentry/systemtest/util/TestHelper { + public fun (Ljava/lang/String;)V + public final fun doesTransactionContainSpanWithDescription (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z + public final fun doesTransactionContainSpanWithOp (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z + public final fun doesTransactionHaveOp (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z + public final fun doesTransactionHaveTraceId (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z + public final fun ensureEnvelopeCountIncreased ()V + public final fun ensureEnvelopeReceived (Lkotlin/jvm/functions/Function1;)V + public final fun ensureErrorCount (Lcom/apollographql/apollo3/api/ApolloResponse;I)V + public final fun ensureErrorReceived (Lkotlin/jvm/functions/Function1;)V + public final fun ensureNoEnvelopeReceived (Lkotlin/jvm/functions/Function1;)V + public final fun ensureNoErrors (Lcom/apollographql/apollo3/api/ApolloResponse;)V + public final fun ensureNoTransactionReceived (Lkotlin/jvm/functions/Function2;)V + public final fun ensureTransactionReceived (Lkotlin/jvm/functions/Function2;)V + public final fun ensureTransactionWithSpanReceived (Lkotlin/jvm/functions/Function1;)V + public final fun getEnvelopeCounts ()Lio/sentry/systemtest/util/EnvelopeCounts; + public final fun getGraphqlClient ()Lio/sentry/systemtest/graphql/GraphqlTestClient; + public final fun getJsonSerializer ()Lio/sentry/JsonSerializer; + public final fun getRestClient ()Lio/sentry/systemtest/util/RestTestClient; + public final fun getSentryClient ()Lio/sentry/systemtest/util/SentryMockServerClient; + public final fun logObject (Ljava/lang/Object;)V + public final fun reset ()V + public final fun setEnvelopeCounts (Lio/sentry/systemtest/util/EnvelopeCounts;)V + public final fun snapshotEnvelopeCount ()V +} + diff --git a/sentry-system-test-support/build.gradle.kts b/sentry-system-test-support/build.gradle.kts new file mode 100644 index 00000000000..b5392694a49 --- /dev/null +++ b/sentry-system-test-support/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + `java-library` + kotlin("jvm") + jacoco + id(Config.QualityPlugins.errorProne) + id(Config.QualityPlugins.gradleVersions) + id("com.apollographql.apollo3") version "3.8.2" +} + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() +} + +dependencies { + api(projects.sentry) + compileOnly(Config.Libs.springBoot3StarterTest) { + exclude(group = "org.junit.vintage", module = "junit-vintage-engine") + } + compileOnly(Config.Libs.springBoot3StarterWeb) + api(Config.Libs.apolloKotlin) + implementation(Config.Libs.jacksonKotlin) + api(projects.sentryTestSupport) + + compileOnly(Config.CompileOnly.nopen) + errorprone(Config.CompileOnly.nopenChecker) + errorprone(Config.CompileOnly.errorprone) + compileOnly(Config.CompileOnly.jetbrainsAnnotations) + + // tests + implementation(kotlin(Config.kotlinStdLib)) + implementation(Config.TestLibs.kotlinTestJunit) + implementation(Config.TestLibs.mockitoKotlin) +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +apollo { + service("service") { + srcDir("src/main/graphql") + packageName.set("io.sentry.samples.graphql") + outputDirConnection { + connectToKotlinSourceSet("main") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/graphql/greeting.graphql b/sentry-system-test-support/src/main/graphql/greeting.graphql similarity index 100% rename from sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/graphql/greeting.graphql rename to sentry-system-test-support/src/main/graphql/greeting.graphql diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/graphql/project.graphql b/sentry-system-test-support/src/main/graphql/project.graphql similarity index 100% rename from sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/graphql/project.graphql rename to sentry-system-test-support/src/main/graphql/project.graphql diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/graphql/schema.graphqls b/sentry-system-test-support/src/main/graphql/schema.graphqls similarity index 100% rename from sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/graphql/schema.graphqls rename to sentry-system-test-support/src/main/graphql/schema.graphqls diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/graphql/task.graphql b/sentry-system-test-support/src/main/graphql/task.graphql similarity index 100% rename from sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/graphql/task.graphql rename to sentry-system-test-support/src/main/graphql/task.graphql diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt new file mode 100644 index 00000000000..fb7721bd942 --- /dev/null +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt @@ -0,0 +1,10 @@ +package io.sentry.systemtest + +data class Todo(val id: Long, val title: String, val isCompleted: Boolean) + +data class Person(val firstName: String, val lastName: String) { + + override fun toString(): String { + return "Person{firstName='$firstName', lastName='$lastName'}" + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt similarity index 100% rename from sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt rename to sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt similarity index 100% rename from sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt rename to sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt similarity index 51% rename from sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt rename to sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt index f50632f381c..7a8792a13bf 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt @@ -1,75 +1,67 @@ package io.sentry.systemtest.util -import io.sentry.samples.spring.boot.jakarta.Person -import io.sentry.samples.spring.boot.jakarta.Todo +import io.sentry.systemtest.Person +import io.sentry.systemtest.Todo import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatusCode +import org.springframework.http.ResponseEntity import org.springframework.web.client.HttpStatusCodeException class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - var lastKnownStatusCode: HttpStatusCode? = null + var lastKnownStatusCode: Int? = null fun getPerson(id: Long): Person? { return try { val response = restTemplate().exchange("$backendBaseUrl/person/{id}", HttpMethod.GET, entityWithAuth(), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode + lastKnownStatusCode = statusCode(response) response.body } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode + lastKnownStatusCode = statusCode(e) null } } - fun createPerson(person: Person): Person? { + fun createPerson(person: Person, extraHeaders: Map? = null): Person? { return try { - val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person), Person::class.java, person) - lastKnownStatusCode = response.statusCode + val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person, extraHeaders), Person::class.java, person) + lastKnownStatusCode = statusCode(response) response.body } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode + lastKnownStatusCode = statusCode(e) null } } - fun getPersonDistributedTracing(id: Long, sentryTraceHeader: String? = null, baggageHeader: String? = null): Person? { + fun getPersonDistributedTracing(id: Long, extraHeaders: Map? = null): Person? { return try { - val response = restTemplate().exchange("$backendBaseUrl/tracing/{id}", HttpMethod.GET, entityWithAuth(headerCallback = tracingHeaders(sentryTraceHeader, baggageHeader)), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode + val response = restTemplate().exchange("$backendBaseUrl/tracing/{id}", HttpMethod.GET, entityWithAuth(extraHeaders = extraHeaders), Person::class.java, mapOf("id" to id)) + lastKnownStatusCode = statusCode(response) response.body } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode + lastKnownStatusCode = statusCode(e) null } } - fun createPersonDistributedTracing(person: Person, sentryTraceHeader: String? = null, baggageHeader: String? = null): Person? { + fun createPersonDistributedTracing(person: Person, extraHeaders: Map? = null): Person? { return try { - val response = restTemplate().exchange("$backendBaseUrl/tracing/", HttpMethod.POST, entityWithAuth(person, tracingHeaders(sentryTraceHeader, baggageHeader)), Person::class.java, person) - lastKnownStatusCode = response.statusCode + val response = restTemplate().exchange("$backendBaseUrl/tracing/", HttpMethod.POST, entityWithAuth(person, extraHeaders), Person::class.java, person) + lastKnownStatusCode = statusCode(response) response.body } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode + lastKnownStatusCode = statusCode(e) null } } - private fun tracingHeaders(sentryTraceHeader: String?, baggageHeader: String?): (HttpHeaders) -> HttpHeaders { - return { httpHeaders -> - sentryTraceHeader?.let { httpHeaders.set("sentry-trace", it) } - baggageHeader?.let { httpHeaders.set("baggage", it) } - httpHeaders - } - } - fun getTodo(id: Long): Todo? { return try { val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode + lastKnownStatusCode = statusCode(response) response.body } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode + lastKnownStatusCode = statusCode(e) null } } @@ -77,10 +69,10 @@ class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestCl fun getTodoWebclient(id: Long): Todo? { return try { val response = restTemplate().exchange("$backendBaseUrl/todo-webclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode + lastKnownStatusCode = statusCode(response) response.body } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode + lastKnownStatusCode = statusCode(e) null } } @@ -88,21 +80,34 @@ class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestCl fun getTodoRestClient(id: Long): Todo? { return try { val response = restTemplate().exchange("$backendBaseUrl/todo-restclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = response.statusCode + lastKnownStatusCode = statusCode(response) response.body } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = e.statusCode + lastKnownStatusCode = statusCode(e) null } } - private fun entityWithAuth(request: Any? = null, headerCallback: ((HttpHeaders) -> HttpHeaders)? = null): HttpEntity { + private fun entityWithAuth(request: Any? = null, extraHeaders: Map? = null): HttpEntity { val headers = HttpHeaders().also { it.setBasicAuth("user", "password") } + extraHeaders?.forEach { key, value -> headers.set(key, value) } + + return HttpEntity(request, headers) + } + + private fun statusCode(o: Any): Int? { + val statusCodeValue = (o as? ResponseEntity)?.statusCodeValue + if (statusCodeValue != null) { + return statusCodeValue + } - val modifiedHeaders = headerCallback?.invoke(headers) ?: headers + val errorStatusCodeValue = (o as? HttpStatusCodeException)?.rawStatusCode + if (errorStatusCodeValue != null) { + return errorStatusCodeValue + } - return HttpEntity(request, modifiedHeaders) + return null } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt similarity index 100% rename from sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt rename to sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt similarity index 95% rename from sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt rename to sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt index 14bac5cd0ea..bbd42fd7415 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -200,4 +200,15 @@ class TestHelper(backendUrl: String) { return true } + + fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { + val matches = transaction.contexts.trace?.operation == op + if (!matches) { + println("Unable to find transaction with op $op:") + logObject(transaction) + return false + } + + return true + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index e5fdc079f79..4c642f1abd6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ include( "sentry-apollo", "sentry-apollo-3", "sentry-apollo-4", + "sentry-system-test-support", "sentry-test-support", "sentry-log4j2", "sentry-logback", From ac9ebcd37106e31ac787798e8dbf980e45741fc9 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Mar 2025 12:40:09 +0100 Subject: [PATCH 026/914] Add distributed tracing tests to more modules (#4237) * Also use port when checking if a request is made to Sentry DSN * changelog * Add a param to control whether the test script should rebuild before running the tested server * Add system tests for distributed tracing * reuse util classes for system tests * add schema * Add distributed tracing tests to more modules * use mono.just for post body --- .../jakarta/DistributedTracingController.java | 49 +++++ .../DistributedTracingSystemTest.kt | 190 ++++++++++++++++++ .../jakarta/DistributedTracingController.java | 49 +++++ .../DistributedTracingSystemTest.kt | 190 ++++++++++++++++++ .../boot/DistributedTracingController.java | 56 ++++++ .../DistributedTracingSystemTest.kt | 190 ++++++++++++++++++ .../boot/DistributedTracingController.java | 56 ++++++ .../DistributedTracingSystemTest.kt | 190 ++++++++++++++++++ .../jakarta/DistributedTracingController.java | 52 +++++ .../DistributedTracingSystemTest.kt | 190 ++++++++++++++++++ .../boot/DistributedTracingController.java | 52 +++++ .../DistributedTracingSystemTest.kt | 190 ++++++++++++++++++ .../boot/DistributedTracingController.java | 56 ++++++ .../DistributedTracingSystemTest.kt | 190 ++++++++++++++++++ 14 files changed, 1700 insertions(+) create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java new file mode 100644 index 00000000000..d67059abb68 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot.jakarta; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestClient restClient; + + public DistributedTracingController(RestClient restClient) { + this.restClient = restClient; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + return restClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restClient + .post() + .uri("http://localhost:8080/person/") + .body(person) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..aa707f8b6e4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,190 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class DistributedTracingSystemTest { + + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java new file mode 100644 index 00000000000..d67059abb68 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot.jakarta; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestClient restClient; + + public DistributedTracingController(RestClient restClient) { + this.restClient = restClient; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + return restClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restClient + .post() + .uri("http://localhost:8080/person/") + .body(person) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..aa707f8b6e4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,190 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class DistributedTracingSystemTest { + + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java new file mode 100644 index 00000000000..5fe91518cf8 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java @@ -0,0 +1,56 @@ +package io.sentry.samples.spring.boot; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestTemplate restTemplate; + + public DistributedTracingController(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + return restTemplate + .exchange( + "http://localhost:8080/person/" + id, + HttpMethod.GET, + new HttpEntity(createHeaders()), + Person.class) + .getBody(); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restTemplate + .exchange( + "http://localhost:8080/person/", + HttpMethod.POST, + new HttpEntity(person, createHeaders()), + Person.class) + .getBody(); + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + + headers.setBasicAuth("user", "password", Charset.defaultCharset()); + + return headers; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..aa707f8b6e4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,190 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class DistributedTracingSystemTest { + + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java new file mode 100644 index 00000000000..5fe91518cf8 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java @@ -0,0 +1,56 @@ +package io.sentry.samples.spring.boot; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestTemplate restTemplate; + + public DistributedTracingController(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + return restTemplate + .exchange( + "http://localhost:8080/person/" + id, + HttpMethod.GET, + new HttpEntity(createHeaders()), + Person.class) + .getBody(); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restTemplate + .exchange( + "http://localhost:8080/person/", + HttpMethod.POST, + new HttpEntity(person, createHeaders()), + Person.class) + .getBody(); + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + + headers.setBasicAuth("user", "password", Charset.defaultCharset()); + + return headers; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..aa707f8b6e4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,190 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class DistributedTracingSystemTest { + + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java new file mode 100644 index 00000000000..38409509905 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java @@ -0,0 +1,52 @@ +package io.sentry.samples.spring.boot.jakarta; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final WebClient webClient; + + public DistributedTracingController(WebClient webClient) { + this.webClient = webClient; + } + + @GetMapping("{id}") + Mono person(@PathVariable Long id) { + return webClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .bodyToMono(Person.class) + .map(response -> response); + } + + @PostMapping + Mono create(@RequestBody Person person) { + return webClient + .post() + .uri("http://localhost:8080/person/") + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .body(Mono.just(person), Person.class) + .retrieve() + .bodyToMono(Person.class) + .map(response -> response); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..aa707f8b6e4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,190 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class DistributedTracingSystemTest { + + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java new file mode 100644 index 00000000000..cd69d854006 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java @@ -0,0 +1,52 @@ +package io.sentry.samples.spring.boot; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final WebClient webClient; + + public DistributedTracingController(WebClient webClient) { + this.webClient = webClient; + } + + @GetMapping("{id}") + Mono person(@PathVariable Long id) { + return webClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .bodyToMono(Person.class) + .map(response -> response); + } + + @PostMapping + Mono create(@RequestBody Person person) { + return webClient + .post() + .uri("http://localhost:8080/person/") + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .body(Mono.just(person), Person.class) + .retrieve() + .bodyToMono(Person.class) + .map(response -> response); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..aa707f8b6e4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,190 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class DistributedTracingSystemTest { + + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java new file mode 100644 index 00000000000..5fe91518cf8 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/DistributedTracingController.java @@ -0,0 +1,56 @@ +package io.sentry.samples.spring.boot; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestTemplate restTemplate; + + public DistributedTracingController(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + return restTemplate + .exchange( + "http://localhost:8080/person/" + id, + HttpMethod.GET, + new HttpEntity(createHeaders()), + Person.class) + .getBody(); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restTemplate + .exchange( + "http://localhost:8080/person/", + HttpMethod.POST, + new HttpEntity(person, createHeaders()), + Person.class) + .getBody(); + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + + headers.setBasicAuth("user", "password", Charset.defaultCharset()); + + return headers; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..aa707f8b6e4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,190 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class DistributedTracingSystemTest { + + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + + val matches = transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET" + ) + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} From 5eb3279befbe47857eef30b7d32e54cbf36e1d70 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Mar 2025 13:25:51 +0100 Subject: [PATCH 027/914] Replace RestTemplate with OkHttp for system tests (#4239) * Also use port when checking if a request is made to Sentry DSN * changelog * Add a param to control whether the test script should rebuild before running the tested server * Add system tests for distributed tracing * reuse util classes for system tests * add schema * Add distributed tracing tests to more modules * use mono.just for post body * Replace RestTemplate with OkHttp in system tests * Format code * format + api * Update buildSrc/src/main/java/Config.kt --------- Co-authored-by: Sentry Github Bot --- buildSrc/src/main/java/Config.kt | 3 +- .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 1 + .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../api/sentry-system-test-support.api | 13 +- sentry-system-test-support/build.gradle.kts | 2 + .../io/sentry/systemtest/ResponseTypes.kt | 2 +- .../util/LoggingInsecureRestClient.kt | 66 +++++++++- .../sentry/systemtest/util/RestTestClient.kt | 114 +++++------------- .../systemtest/util/SentryMockServerClient.kt | 24 ++-- 15 files changed, 129 insertions(+), 111 deletions(-) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index b0e56eb2dc4..e24edb6e3d0 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -69,12 +69,13 @@ object Config { val slf4jJdk14 = "org.slf4j:slf4j-jdk14:1.7.30" val logbackVersion = "1.2.9" val logbackClassic = "ch.qos.logback:logback-classic:$logbackVersion" + val logbackCore = "ch.qos.logback:logback-core:$logbackVersion" val log4j2Version = "2.20.0" val log4j2Api = "org.apache.logging.log4j:log4j-api:$log4j2Version" val log4j2Core = "org.apache.logging.log4j:log4j-core:$log4j2Version" - val jacksonDatabind = "com.fasterxml.jackson.core:jackson-databind" + val jacksonDatabind = "com.fasterxml.jackson.core:jackson-databind:2.18.3" val jacksonKotlin = "com.fasterxml.jackson.module:jackson-module-kotlin:2.18.3" val springBootStarter = "org.springframework.boot:spring-boot-starter:$springBootVersion" diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index 1f31fbfc05f..24f7f422dfb 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation("ch.qos.logback:logback-classic:1.5.16") + testImplementation("ch.qos.logback:logback-core:1.5.16") testImplementation(Config.Libs.slf4jApi2) testImplementation(Config.Libs.apolloKotlin) } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index dce546c8c4e..bbea7d9cc52 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation("ch.qos.logback:logback-classic:1.5.16") + testImplementation("ch.qos.logback:logback-core:1.5.16") testImplementation(Config.Libs.slf4jApi2) testImplementation(Config.Libs.apolloKotlin) } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 11e6b613466..d4d62a8d231 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation("ch.qos.logback:logback-classic:1.5.16") + testImplementation("ch.qos.logback:logback-core:1.5.16") testImplementation(Config.Libs.slf4jApi2) testImplementation(Config.Libs.apolloKotlin) testImplementation(projects.sentry) diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index fa30b6d3b16..1a2f12d804d 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -63,7 +63,8 @@ dependencies { } testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) - testImplementation(Config.Libs.logbackClassic) + testImplementation("ch.qos.logback:logback-classic:1.5.16") + testImplementation("ch.qos.logback:logback-core:1.5.16") testImplementation(Config.Libs.slf4jApi2) testImplementation(Config.Libs.apolloKotlin) testImplementation("org.apache.httpcomponents:httpclient") diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index 5bf0d001881..a98538eaab9 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -64,7 +64,8 @@ dependencies { } testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) - testImplementation(Config.Libs.logbackClassic) + testImplementation("ch.qos.logback:logback-classic:1.5.16") + testImplementation("ch.qos.logback:logback-core:1.5.16") testImplementation(Config.Libs.slf4jApi2) testImplementation(Config.Libs.apolloKotlin) testImplementation("org.apache.httpcomponents:httpclient") diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index 6d390ce15fa..fc7aa196cd4 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation("ch.qos.logback:logback-classic:1.5.16") + testImplementation("ch.qos.logback:logback-core:1.5.16") testImplementation(Config.Libs.slf4jApi2) testImplementation(Config.Libs.apolloKotlin) } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index 3e50d013107..9d2ea18d2d6 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -33,7 +33,8 @@ dependencies { } testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) - testImplementation(Config.Libs.logbackClassic) + testImplementation("ch.qos.logback:logback-classic:1.5.16") + testImplementation("ch.qos.logback:logback-core:1.5.16") testImplementation(Config.Libs.slf4jApi2) testImplementation(Config.Libs.apolloKotlin) testImplementation("org.apache.httpcomponents:httpclient") diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 3ffdcc8bef0..3e9483ae2c2 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -61,7 +61,8 @@ dependencies { } testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) - testImplementation(Config.Libs.logbackClassic) + testImplementation("ch.qos.logback:logback-classic:1.5.16") + testImplementation("ch.qos.logback:logback-core:1.5.16") testImplementation(Config.Libs.slf4jApi2) testImplementation(Config.Libs.apolloKotlin) testImplementation("org.apache.httpcomponents:httpclient") diff --git a/sentry-system-test-support/api/sentry-system-test-support.api b/sentry-system-test-support/api/sentry-system-test-support.api index f91725edfbf..7c7041922ba 100644 --- a/sentry-system-test-support/api/sentry-system-test-support.api +++ b/sentry-system-test-support/api/sentry-system-test-support.api @@ -488,10 +488,10 @@ public final class io/sentry/systemtest/Todo { public final fun copy (JLjava/lang/String;Z)Lio/sentry/systemtest/Todo; public static synthetic fun copy$default (Lio/sentry/systemtest/Todo;JLjava/lang/String;ZILjava/lang/Object;)Lio/sentry/systemtest/Todo; public fun equals (Ljava/lang/Object;)Z + public final fun getCompleted ()Z public final fun getId ()J public final fun getTitle ()Ljava/lang/String; public fun hashCode ()I - public final fun isCompleted ()Z public fun toString ()Ljava/lang/String; } @@ -518,7 +518,14 @@ public final class io/sentry/systemtest/util/EnvelopesReceived { public class io/sentry/systemtest/util/LoggingInsecureRestClient { public fun ()V - protected final fun restTemplate ()Lorg/springframework/web/client/RestTemplate; + protected final fun call (Lokhttp3/Request$Builder;ZLjava/util/Map;)Lokhttp3/Response; + public static synthetic fun call$default (Lio/sentry/systemtest/util/LoggingInsecureRestClient;Lokhttp3/Request$Builder;ZLjava/util/Map;ILjava/lang/Object;)Lokhttp3/Response; + protected final fun client ()Lokhttp3/OkHttpClient; + public final fun getLastKnownStatusCode ()Ljava/lang/Integer; + public final fun getLogger ()Lorg/slf4j/Logger; + protected final fun objectMapper ()Lcom/fasterxml/jackson/databind/ObjectMapper; + public final fun setLastKnownStatusCode (Ljava/lang/Integer;)V + protected final fun toRequestBody (Ljava/lang/Object;)Lokhttp3/RequestBody; } public final class io/sentry/systemtest/util/RestTestClient : io/sentry/systemtest/util/LoggingInsecureRestClient { @@ -527,14 +534,12 @@ public final class io/sentry/systemtest/util/RestTestClient : io/sentry/systemte public static synthetic fun createPerson$default (Lio/sentry/systemtest/util/RestTestClient;Lio/sentry/systemtest/Person;Ljava/util/Map;ILjava/lang/Object;)Lio/sentry/systemtest/Person; public final fun createPersonDistributedTracing (Lio/sentry/systemtest/Person;Ljava/util/Map;)Lio/sentry/systemtest/Person; public static synthetic fun createPersonDistributedTracing$default (Lio/sentry/systemtest/util/RestTestClient;Lio/sentry/systemtest/Person;Ljava/util/Map;ILjava/lang/Object;)Lio/sentry/systemtest/Person; - public final fun getLastKnownStatusCode ()Ljava/lang/Integer; public final fun getPerson (J)Lio/sentry/systemtest/Person; public final fun getPersonDistributedTracing (JLjava/util/Map;)Lio/sentry/systemtest/Person; public static synthetic fun getPersonDistributedTracing$default (Lio/sentry/systemtest/util/RestTestClient;JLjava/util/Map;ILjava/lang/Object;)Lio/sentry/systemtest/Person; public final fun getTodo (J)Lio/sentry/systemtest/Todo; public final fun getTodoRestClient (J)Lio/sentry/systemtest/Todo; public final fun getTodoWebclient (J)Lio/sentry/systemtest/Todo; - public final fun setLastKnownStatusCode (Ljava/lang/Integer;)V } public final class io/sentry/systemtest/util/SentryMockServerClient : io/sentry/systemtest/util/LoggingInsecureRestClient { diff --git a/sentry-system-test-support/build.gradle.kts b/sentry-system-test-support/build.gradle.kts index b5392694a49..6008dd10b60 100644 --- a/sentry-system-test-support/build.gradle.kts +++ b/sentry-system-test-support/build.gradle.kts @@ -24,7 +24,9 @@ dependencies { compileOnly(Config.Libs.springBoot3StarterWeb) api(Config.Libs.apolloKotlin) implementation(Config.Libs.jacksonKotlin) + implementation(Config.Libs.jacksonDatabind) api(projects.sentryTestSupport) + implementation(Config.Libs.okhttp) compileOnly(Config.CompileOnly.nopen) errorprone(Config.CompileOnly.nopenChecker) diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt index fb7721bd942..7805962118e 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt @@ -1,6 +1,6 @@ package io.sentry.systemtest -data class Todo(val id: Long, val title: String, val isCompleted: Boolean) +data class Todo(val id: Long, val title: String, val completed: Boolean) data class Person(val firstName: String, val lastName: String) { diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt index 17eea1a0084..e1773f1c0e7 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt @@ -1,13 +1,69 @@ package io.sentry.systemtest.util -import org.springframework.http.client.BufferingClientHttpRequestFactory -import org.springframework.web.client.RestTemplate +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import okhttp3.Credentials +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.slf4j.LoggerFactory open class LoggingInsecureRestClient { + val logger = LoggerFactory.getLogger(LoggingInsecureRestClient::class.java) + var lastKnownStatusCode: Int? = null - protected fun restTemplate(): RestTemplate { - return RestTemplate().also { - it.requestFactory = BufferingClientHttpRequestFactory(it.requestFactory) + protected inline fun callTyped(requestBuilder: Request.Builder, useAuth: Boolean, extraHeaders: Map? = null): T? { + val response = call(requestBuilder, useAuth, extraHeaders) + val responseBody = response?.body?.string() + if (response?.isSuccessful != true) { + return null } + return responseBody?.let { objectMapper().readValue(it, T::class.java) } + } + + protected fun call(requestBuilder: Request.Builder, useAuth: Boolean, extraHeaders: Map? = null): Response? { + try { + val request = requestBuilder.also { originalRequest -> + var modifiedRequest = originalRequest + + if (useAuth) { + modifiedRequest = modifiedRequest.header( + "Authorization", + Credentials.basic("user", "password") + ) + } + + extraHeaders?.forEach { key, value -> + modifiedRequest = modifiedRequest.header(key, value) + } + + modifiedRequest + }.build() + val call = client().newCall(request) + val response = call.execute() + lastKnownStatusCode = response.code + return response + } catch (e: Exception) { + lastKnownStatusCode = null + logger.error("Request failed", e) + return null + } + } + + protected fun client(): OkHttpClient { + return OkHttpClient.Builder() + .build() + } + + protected fun objectMapper(): ObjectMapper { + return jacksonObjectMapper() + } + + protected fun toRequestBody(o: Any?): RequestBody { + val stringValue = objectMapper().writeValueAsString(o) + return stringValue.toRequestBody("application/json; charset=utf-8".toMediaType()) } } diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt index 7a8792a13bf..2a1ce42e325 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt @@ -2,112 +2,58 @@ package io.sentry.systemtest.util import io.sentry.systemtest.Person import io.sentry.systemtest.Todo -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.ResponseEntity -import org.springframework.web.client.HttpStatusCodeException +import okhttp3.Request class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - var lastKnownStatusCode: Int? = null fun getPerson(id: Long): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/{id}", HttpMethod.GET, entityWithAuth(), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = statusCode(response) - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = statusCode(e) - null - } + val request = Request.Builder() + .url("$backendBaseUrl/person/$id") + + return callTyped(request, true) } fun createPerson(person: Person, extraHeaders: Map? = null): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person, extraHeaders), Person::class.java, person) - lastKnownStatusCode = statusCode(response) - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = statusCode(e) - null - } + val request = Request.Builder() + .url("$backendBaseUrl/person/") + .post(toRequestBody(person)) + + return callTyped(request, true, extraHeaders) } fun getPersonDistributedTracing(id: Long, extraHeaders: Map? = null): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/tracing/{id}", HttpMethod.GET, entityWithAuth(extraHeaders = extraHeaders), Person::class.java, mapOf("id" to id)) - lastKnownStatusCode = statusCode(response) - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = statusCode(e) - null - } + val request = Request.Builder() + .url("$backendBaseUrl/tracing/$id") + + return callTyped(request, true, extraHeaders) } fun createPersonDistributedTracing(person: Person, extraHeaders: Map? = null): Person? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/tracing/", HttpMethod.POST, entityWithAuth(person, extraHeaders), Person::class.java, person) - lastKnownStatusCode = statusCode(response) - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = statusCode(e) - null - } - } + val request = Request.Builder() + .url("$backendBaseUrl/tracing/") + .post(toRequestBody(person)) - fun getTodo(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = statusCode(response) - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = statusCode(e) - null - } + return callTyped(request, true, extraHeaders) } - fun getTodoWebclient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-webclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = statusCode(response) - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = statusCode(e) - null - } - } + fun getTodo(id: Long): Todo? { + val request = Request.Builder() + .url("$backendBaseUrl/todo/$id") - fun getTodoRestClient(id: Long): Todo? { - return try { - val response = restTemplate().exchange("$backendBaseUrl/todo-restclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) - lastKnownStatusCode = statusCode(response) - response.body - } catch (e: HttpStatusCodeException) { - lastKnownStatusCode = statusCode(e) - null - } + return callTyped(request, true) } - private fun entityWithAuth(request: Any? = null, extraHeaders: Map? = null): HttpEntity { - val headers = HttpHeaders().also { - it.setBasicAuth("user", "password") - } - extraHeaders?.forEach { key, value -> headers.set(key, value) } + fun getTodoWebclient(id: Long): Todo? { + val request = Request.Builder() + .url("$backendBaseUrl/todo-webclient/$id") - return HttpEntity(request, headers) + return callTyped(request, true) } - private fun statusCode(o: Any): Int? { - val statusCodeValue = (o as? ResponseEntity)?.statusCodeValue - if (statusCodeValue != null) { - return statusCodeValue - } - - val errorStatusCodeValue = (o as? HttpStatusCodeException)?.rawStatusCode - if (errorStatusCodeValue != null) { - return errorStatusCodeValue - } + fun getTodoRestClient(id: Long): Todo? { + val request = Request.Builder() + .url("$backendBaseUrl/todo-restclient/$id") - return null + return callTyped(request, true) } } diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt index 7ef1699f122..b41986656ef 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt @@ -1,28 +1,28 @@ package io.sentry.systemtest.util -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod +import okhttp3.Request class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { fun getEnvelopeCount(): EnvelopeCounts { - val response = restTemplate().exchange("$baseUrl/envelope-count", HttpMethod.GET, entityWithAuth(), EnvelopeCounts::class.java) - return response.body!! + val request = Request.Builder() + .url("$baseUrl/envelope-count") + + return callTyped(request, false)!! } fun reset() { - restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) + val request = Request.Builder() + .url("$baseUrl/reset") + + call(request, false) } fun getEnvelopes(): EnvelopesReceived { - val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) - return response.body!! - } + val request = Request.Builder() + .url("$baseUrl/envelopes-received") - private fun entityWithAuth(request: Any? = null): HttpEntity { - val headers = HttpHeaders() - return HttpEntity(request, headers) + return callTyped(request, false)!! } } From 21a214bec3d545c66ecf8c61446aa0aa8f34c625 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 12 Mar 2025 06:32:52 +0100 Subject: [PATCH 028/914] Avoid NPEs from SDK in API like setTag, setData, setContext (#4245) * Avoid NPEs from SDK in API like setTag, setData, setContext * changelog * revert unintentional change * Update CHANGELOG.md * set timeouts for okhttp in system tests * bump timeout --- CHANGELOG.md | 16 +- .../android/core/InternalSentrySdkTest.kt | 2 +- .../OtelStrongRefSpanWrapper.java | 10 +- .../OtelTransactionSpanForwarder.java | 10 +- .../sentry/opentelemetry/OtelSpanWrapper.java | 25 ++- .../util/LoggingInsecureRestClient.kt | 5 + sentry/api/sentry.api | 3 + .../src/main/java/io/sentry/Breadcrumb.java | 21 ++- .../java/io/sentry/CombinedContextsView.java | 26 ++- .../java/io/sentry/CombinedScopeView.java | 24 +-- .../src/main/java/io/sentry/HubAdapter.java | 8 +- .../main/java/io/sentry/HubScopesWrapper.java | 8 +- sentry/src/main/java/io/sentry/IScope.java | 24 +-- sentry/src/main/java/io/sentry/IScopes.java | 8 +- sentry/src/main/java/io/sentry/ISpan.java | 10 +- sentry/src/main/java/io/sentry/NoOpHub.java | 8 +- sentry/src/main/java/io/sentry/NoOpScope.java | 24 +-- .../src/main/java/io/sentry/NoOpScopes.java | 8 +- sentry/src/main/java/io/sentry/NoOpSpan.java | 10 +- .../main/java/io/sentry/NoOpTransaction.java | 10 +- sentry/src/main/java/io/sentry/Scope.java | 150 +++++++++++++----- sentry/src/main/java/io/sentry/Scopes.java | 8 +- .../main/java/io/sentry/ScopesAdapter.java | 8 +- sentry/src/main/java/io/sentry/Sentry.java | 8 +- .../main/java/io/sentry/SentryBaseEvent.java | 38 +++-- .../main/java/io/sentry/SentryOptions.java | 11 +- .../src/main/java/io/sentry/SentryTracer.java | 10 +- sentry/src/main/java/io/sentry/Span.java | 25 ++- .../src/main/java/io/sentry/SpanContext.java | 24 ++- .../java/io/sentry/protocol/Contexts.java | 52 ++++-- .../src/test/java/io/sentry/BreadcrumbTest.kt | 39 +++++ .../io/sentry/CombinedContextsViewTest.kt | 27 ++++ .../java/io/sentry/CombinedScopeViewTest.kt | 57 +++++++ sentry/src/test/java/io/sentry/ScopeTest.kt | 57 +++++++ sentry/src/test/java/io/sentry/ScopesTest.kt | 24 +++ .../test/java/io/sentry/SentryEventTest.kt | 29 ++++ .../test/java/io/sentry/SentryOptionsTest.kt | 9 ++ .../test/java/io/sentry/SentryTracerTest.kt | 23 +++ .../test/java/io/sentry/SpanContextTest.kt | 19 +++ sentry/src/test/java/io/sentry/SpanTest.kt | 33 ++++ .../java/io/sentry/protocol/ContextsTest.kt | 84 ++++++++++ 41 files changed, 802 insertions(+), 193 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4574a0fceb..d6ddff9da8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,14 @@ ## Unreleased -### Behavioural Changes - -- Use `java.net.URI` for parsing URLs in `UrlUtils` ([#4210](https://github.com/getsentry/sentry-java/pull/4210)) - - This could affect grouping for issues with messages containing URLs that fall in known corner cases that were handled incorrectly previously (e.g. email in URL path) - ### Fixes +- The SDK now handles `null` on many APIs instead of expecting a non `null` value ([#4245](https://github.com/getsentry/sentry-java/pull/4245)) + - Certain APIs like `setTag`, `setData`, `setExtra`, `setContext` previously caused a `NullPointerException` when invoked with either `null` key or value. + - The SDK now tries to have a sane fallback when `null` is passed and no longer throws `NullPointerException` + - If `null` is passed, the SDK will + - do nothing if a `null` key is passed, returning `null` for non void methods + - remove any previous value if the new value is set to `null` - Add support for setting in-app-includes/in-app-excludes via AndroidManifest.xml ([#4240](https://github.com/getsentry/sentry-java/pull/4240)) - Modifications to OkHttp requests are now properly propagated to the affected span / breadcrumbs ([#4238](https://github.com/getsentry/sentry-java/pull/4238)) - Please ensure the SentryOkHttpInterceptor is added last to your OkHttpClient, as otherwise changes to the `Request` by subsequent interceptors won't be considered @@ -22,6 +23,11 @@ - Set `sentry.capture-open-telemetry-events=true` in Springs `application.properties` to enable it - Set `sentry.captureOpenTelemetryEvents: true` in Springs `application.yml` to enable it +### Behavioural Changes + +- Use `java.net.URI` for parsing URLs in `UrlUtils` ([#4210](https://github.com/getsentry/sentry-java/pull/4210)) + - This could affect grouping for issues with messages containing URLs that fall in known corner cases that were handled incorrectly previously (e.g. email in URL path) + ### Internal - Also use port when checking if a request is made to Sentry DSN ([#4231](https://github.com/getsentry/sentry-java/pull/4231)) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index 7ddabb84ea7..58468fc59ca 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -319,7 +319,7 @@ class InternalSentrySdkTest { fun `serializeScope provides fallback app data if none is set`() { val options = SentryAndroidOptions() val scope = Scope(options) - scope.setContexts("app", null) + scope.setContexts("app", null as Any?) val serializedScope = InternalSentrySdk.serializeScope(context, options, scope) assertTrue(((serializedScope["contexts"] as Map<*, *>)["app"] as Map<*, *>).containsKey("app_name")) diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java index 7f026742e9f..a4008a01283 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java @@ -225,12 +225,12 @@ public void setThrowable(@Nullable Throwable throwable) { } @Override - public void setTag(@NotNull String key, @NotNull String value) { + public void setTag(@Nullable String key, @Nullable String value) { delegate.setTag(key, value); } @Override - public @Nullable String getTag(@NotNull String key) { + public @Nullable String getTag(@Nullable String key) { return delegate.getTag(key); } @@ -240,12 +240,12 @@ public boolean isFinished() { } @Override - public void setData(@NotNull String key, @NotNull Object value) { + public void setData(@Nullable String key, @Nullable Object value) { delegate.setData(key, value); } @Override - public @Nullable Object getData(@NotNull String key) { + public @Nullable Object getData(@Nullable String key) { return delegate.getData(key); } @@ -281,7 +281,7 @@ public boolean isNoOp() { } @Override - public void setContext(@NotNull String key, @NotNull Object context) { + public void setContext(@Nullable String key, @Nullable Object context) { delegate.setContext(key, context); } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java index 18e5d1b6db1..7d0618af040 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java @@ -152,12 +152,12 @@ public void setThrowable(@Nullable Throwable throwable) { } @Override - public void setTag(@NotNull String key, @NotNull String value) { + public void setTag(@Nullable String key, @Nullable String value) { rootSpan.setTag(key, value); } @Override - public @Nullable String getTag(@NotNull String key) { + public @Nullable String getTag(@Nullable String key) { return rootSpan.getTag(key); } @@ -167,12 +167,12 @@ public boolean isFinished() { } @Override - public void setData(@NotNull String key, @NotNull Object value) { + public void setData(@Nullable String key, @Nullable Object value) { rootSpan.setData(key, value); } @Override - public @Nullable Object getData(@NotNull String key) { + public @Nullable Object getData(@Nullable String key) { return rootSpan.getData(key); } @@ -277,7 +277,7 @@ public void finish( } @Override - public void setContext(@NotNull String key, @NotNull Object context) { + public void setContext(@Nullable String key, @Nullable Object context) { // thoughts: // - span would have to save it on global storage too since we can't add complex data to otel // span diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java index 8d11cb8b772..bc78643fb9b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java @@ -330,12 +330,15 @@ public void setThrowable(@Nullable Throwable throwable) { } @Override - public void setTag(@NotNull String key, @NotNull String value) { + public void setTag(@Nullable String key, @Nullable String value) { context.setTag(key, value); } @Override - public @Nullable String getTag(@NotNull String key) { + public @Nullable String getTag(@Nullable String key) { + if (key == null) { + return null; + } return context.getTags().get(key); } @@ -357,12 +360,22 @@ public boolean isFinished() { } @Override - public void setData(@NotNull String key, @NotNull Object value) { - data.put(key, value); + public void setData(@Nullable String key, @Nullable Object value) { + if (key == null) { + return; + } + if (value == null) { + data.remove(key); + } else { + data.put(key, value); + } } @Override - public @Nullable Object getData(@NotNull String key) { + public @Nullable Object getData(@Nullable String key) { + if (key == null) { + return null; + } return data.get(key); } @@ -422,7 +435,7 @@ public boolean isNoOp() { } @Override - public void setContext(@NotNull String key, @NotNull Object context) { + public void setContext(@Nullable String key, @Nullable Object context) { contexts.put(key, context); } diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt index e1773f1c0e7..72dcc34f5f6 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt @@ -10,6 +10,7 @@ import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.slf4j.LoggerFactory +import java.util.concurrent.TimeUnit open class LoggingInsecureRestClient { val logger = LoggerFactory.getLogger(LoggingInsecureRestClient::class.java) @@ -55,6 +56,10 @@ open class LoggingInsecureRestClient { protected fun client(): OkHttpClient { return OkHttpClient.Builder() + .callTimeout(60, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) .build() } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fb29473c4ac..fef0f23bfd6 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -238,8 +238,11 @@ public final class io/sentry/CombinedContextsView : io/sentry/protocol/Contexts public fun isEmpty ()Z public fun keys ()Ljava/util/Enumeration; public fun put (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; + public fun putAll (Lio/sentry/protocol/Contexts;)V + public fun putAll (Ljava/util/Map;)V public fun remove (Ljava/lang/Object;)Ljava/lang/Object; public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun set (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; public fun setApp (Lio/sentry/protocol/App;)V public fun setBrowser (Lio/sentry/protocol/Browser;)V public fun setDevice (Lio/sentry/protocol/Device;)V diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index d5096455108..d5bb66d33e1 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -616,7 +616,10 @@ public Map getData() { * @return the value or null */ @Nullable - public Object getData(final @NotNull String key) { + public Object getData(final @Nullable String key) { + if (key == null) { + return null; + } return data.get(key); } @@ -626,8 +629,15 @@ public Object getData(final @NotNull String key) { * @param key the key * @param value the value */ - public void setData(@NotNull String key, @NotNull Object value) { - data.put(key, value); + public void setData(@Nullable String key, @Nullable Object value) { + if (key == null) { + return; + } + if (value == null) { + removeData(key); + } else { + data.put(key, value); + } } /** @@ -635,7 +645,10 @@ public void setData(@NotNull String key, @NotNull Object value) { * * @param key the key */ - public void removeData(@NotNull String key) { + public void removeData(@Nullable String key) { + if (key == null) { + return; + } data.remove(key); } diff --git a/sentry/src/main/java/io/sentry/CombinedContextsView.java b/sentry/src/main/java/io/sentry/CombinedContextsView.java index 11e459877d6..31b5c060620 100644 --- a/sentry/src/main/java/io/sentry/CombinedContextsView.java +++ b/sentry/src/main/java/io/sentry/CombinedContextsView.java @@ -241,14 +241,14 @@ public boolean isEmpty() { } @Override - public boolean containsKey(final @NotNull Object key) { + public boolean containsKey(final @Nullable Object key) { return globalContexts.containsKey(key) || isolationContexts.containsKey(key) || currentContexts.containsKey(key); } @Override - public @Nullable Object get(final @NotNull Object key) { + public @Nullable Object get(final @Nullable Object key) { final @Nullable Object current = currentContexts.get(key); if (current != null) { return current; @@ -261,12 +261,12 @@ public boolean containsKey(final @NotNull Object key) { } @Override - public @Nullable Object put(final @NotNull String key, final @Nullable Object value) { + public @Nullable Object put(final @Nullable String key, final @Nullable Object value) { return getDefaultContexts().put(key, value); } @Override - public @Nullable Object remove(final @NotNull Object key) { + public @Nullable Object remove(final @Nullable Object key) { return getDefaultContexts().remove(key); } @@ -281,10 +281,26 @@ public boolean containsKey(final @NotNull Object key) { } @Override - public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { mergeContexts().serialize(writer, logger); } + @Override + public @Nullable Object set(@Nullable String key, @Nullable Object value) { + return put(key, value); + } + + @Override + public void putAll(@Nullable Map m) { + getDefaultContexts().putAll(m); + } + + @Override + public void putAll(@Nullable Contexts contexts) { + getDefaultContexts().putAll(contexts); + } + private @NotNull Contexts mergeContexts() { final @NotNull Contexts allContexts = new Contexts(); allContexts.putAll(globalContexts); diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index 129066450f3..d6ac5b824a9 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -229,12 +229,12 @@ public void clear() { } @Override - public void setTag(@NotNull String key, @NotNull String value) { + public void setTag(@Nullable String key, @Nullable String value) { getDefaultWriteScope().setTag(key, value); } @Override - public void removeTag(@NotNull String key) { + public void removeTag(@Nullable String key) { getDefaultWriteScope().removeTag(key); } @@ -248,12 +248,12 @@ public void removeTag(@NotNull String key) { } @Override - public void setExtra(@NotNull String key, @NotNull String value) { + public void setExtra(@Nullable String key, @Nullable String value) { getDefaultWriteScope().setExtra(key, value); } @Override - public void removeExtra(@NotNull String key) { + public void removeExtra(@Nullable String key) { getDefaultWriteScope().removeExtra(key); } @@ -267,42 +267,42 @@ public void removeExtra(@NotNull String key) { } @Override - public void setContexts(@NotNull String key, @NotNull Object value) { + public void setContexts(@Nullable String key, @Nullable Object value) { getDefaultWriteScope().setContexts(key, value); } @Override - public void setContexts(@NotNull String key, @NotNull Boolean value) { + public void setContexts(@Nullable String key, @Nullable Boolean value) { getDefaultWriteScope().setContexts(key, value); } @Override - public void setContexts(@NotNull String key, @NotNull String value) { + public void setContexts(@Nullable String key, @Nullable String value) { getDefaultWriteScope().setContexts(key, value); } @Override - public void setContexts(@NotNull String key, @NotNull Number value) { + public void setContexts(@Nullable String key, @Nullable Number value) { getDefaultWriteScope().setContexts(key, value); } @Override - public void setContexts(@NotNull String key, @NotNull Collection value) { + public void setContexts(@Nullable String key, @Nullable Collection value) { getDefaultWriteScope().setContexts(key, value); } @Override - public void setContexts(@NotNull String key, @NotNull Object[] value) { + public void setContexts(@Nullable String key, @Nullable Object[] value) { getDefaultWriteScope().setContexts(key, value); } @Override - public void setContexts(@NotNull String key, @NotNull Character value) { + public void setContexts(@Nullable String key, @Nullable Character value) { getDefaultWriteScope().setContexts(key, value); } @Override - public void removeContexts(@NotNull String key) { + public void removeContexts(@Nullable String key) { getDefaultWriteScope().removeContexts(key); } diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index fc2f9c15dcd..7fba4da99d8 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -128,22 +128,22 @@ public void clearBreadcrumbs() { } @Override - public void setTag(@NotNull String key, @NotNull String value) { + public void setTag(@Nullable String key, @Nullable String value) { Sentry.setTag(key, value); } @Override - public void removeTag(@NotNull String key) { + public void removeTag(@Nullable String key) { Sentry.removeTag(key); } @Override - public void setExtra(@NotNull String key, @NotNull String value) { + public void setExtra(@Nullable String key, @Nullable String value) { Sentry.setExtra(key, value); } @Override - public void removeExtra(@NotNull String key) { + public void removeExtra(@Nullable String key) { Sentry.removeExtra(key); } diff --git a/sentry/src/main/java/io/sentry/HubScopesWrapper.java b/sentry/src/main/java/io/sentry/HubScopesWrapper.java index 591852a9adf..9ca84df9a17 100644 --- a/sentry/src/main/java/io/sentry/HubScopesWrapper.java +++ b/sentry/src/main/java/io/sentry/HubScopesWrapper.java @@ -123,22 +123,22 @@ public void clearBreadcrumbs() { } @Override - public void setTag(@NotNull String key, @NotNull String value) { + public void setTag(@Nullable String key, @Nullable String value) { scopes.setTag(key, value); } @Override - public void removeTag(@NotNull String key) { + public void removeTag(@Nullable String key) { scopes.removeTag(key); } @Override - public void setExtra(@NotNull String key, @NotNull String value) { + public void setExtra(@Nullable String key, @Nullable String value) { scopes.setExtra(key, value); } @Override - public void removeExtra(@NotNull String key) { + public void removeExtra(@Nullable String key) { scopes.removeExtra(key); } diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index 3c7c1ecca64..ddabd00569e 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -196,14 +196,14 @@ public interface IScope { * @param key the key * @param value the value */ - void setTag(final @NotNull String key, final @NotNull String value); + void setTag(final @Nullable String key, final @Nullable String value); /** * Removes a tag from the Scope's tags * * @param key the key */ - void removeTag(final @NotNull String key); + void removeTag(final @Nullable String key); /** * Returns the Scope's extra map @@ -220,14 +220,14 @@ public interface IScope { * @param key the key * @param value the value */ - void setExtra(final @NotNull String key, final @NotNull String value); + void setExtra(final @Nullable String key, final @Nullable String value); /** * Removes an extra from the Scope's extras * * @param key the key */ - void removeExtra(final @NotNull String key); + void removeExtra(final @Nullable String key); /** * Returns the Scope's contexts @@ -243,7 +243,7 @@ public interface IScope { * @param key the context key * @param value the context value */ - void setContexts(final @NotNull String key, final @NotNull Object value); + void setContexts(final @Nullable String key, final @Nullable Object value); /** * Sets the Scope's contexts @@ -251,7 +251,7 @@ public interface IScope { * @param key the context key * @param value the context value */ - void setContexts(final @NotNull String key, final @NotNull Boolean value); + void setContexts(final @Nullable String key, final @Nullable Boolean value); /** * Sets the Scope's contexts @@ -259,7 +259,7 @@ public interface IScope { * @param key the context key * @param value the context value */ - void setContexts(final @NotNull String key, final @NotNull String value); + void setContexts(final @Nullable String key, final @Nullable String value); /** * Sets the Scope's contexts @@ -267,7 +267,7 @@ public interface IScope { * @param key the context key * @param value the context value */ - void setContexts(final @NotNull String key, final @NotNull Number value); + void setContexts(final @Nullable String key, final @Nullable Number value); /** * Sets the Scope's contexts @@ -275,7 +275,7 @@ public interface IScope { * @param key the context key * @param value the context value */ - void setContexts(final @NotNull String key, final @NotNull Collection value); + void setContexts(final @Nullable String key, final @Nullable Collection value); /** * Sets the Scope's contexts @@ -283,7 +283,7 @@ public interface IScope { * @param key the context key * @param value the context value */ - void setContexts(final @NotNull String key, final @NotNull Object[] value); + void setContexts(final @Nullable String key, final @Nullable Object[] value); /** * Sets the Scope's contexts @@ -291,14 +291,14 @@ public interface IScope { * @param key the context key * @param value the context value */ - void setContexts(final @NotNull String key, final @NotNull Character value); + void setContexts(final @Nullable String key, final @Nullable Character value); /** * Removes a value from the Scope's contexts * * @param key the Key */ - void removeContexts(final @NotNull String key); + void removeContexts(final @Nullable String key); /** * Returns the Scopes's attachments diff --git a/sentry/src/main/java/io/sentry/IScopes.java b/sentry/src/main/java/io/sentry/IScopes.java index e07de9c327c..c93f558ea57 100644 --- a/sentry/src/main/java/io/sentry/IScopes.java +++ b/sentry/src/main/java/io/sentry/IScopes.java @@ -271,14 +271,14 @@ default void addBreadcrumb(@NotNull String message, @NotNull String category) { * @param key the key * @param value the value */ - void setTag(@NotNull String key, @NotNull String value); + void setTag(@Nullable String key, @Nullable String value); /** * Removes the tag to a string value to the current Scope * * @param key the key */ - void removeTag(@NotNull String key); + void removeTag(@Nullable String key); /** * Sets the extra key to an arbitrary value to the current Scope, overwriting a potential previous @@ -287,14 +287,14 @@ default void addBreadcrumb(@NotNull String message, @NotNull String category) { * @param key the key * @param value the value */ - void setExtra(@NotNull String key, @NotNull String value); + void setExtra(@Nullable String key, @Nullable String value); /** * Removes the extra key to an arbitrary value to the current Scope * * @param key the key */ - void removeExtra(@NotNull String key); + void removeExtra(@Nullable String key); /** * Last event id recorded in the current scope diff --git a/sentry/src/main/java/io/sentry/ISpan.java b/sentry/src/main/java/io/sentry/ISpan.java index f915f60cb5d..0765f5127f9 100644 --- a/sentry/src/main/java/io/sentry/ISpan.java +++ b/sentry/src/main/java/io/sentry/ISpan.java @@ -171,10 +171,10 @@ ISpan startChild( * @param key the tag key * @param value the tag value */ - void setTag(@NotNull String key, @NotNull String value); + void setTag(@Nullable String key, @Nullable String value); @Nullable - String getTag(@NotNull String key); + String getTag(@Nullable String key); /** * Returns if span has finished. @@ -189,7 +189,7 @@ ISpan startChild( * @param key the data key * @param value the data value */ - void setData(@NotNull String key, @NotNull Object value); + void setData(@Nullable String key, @Nullable Object value); /** * Returns extra data from span or transaction. @@ -197,7 +197,7 @@ ISpan startChild( * @return the data */ @Nullable - Object getData(@NotNull String key); + Object getData(@Nullable String key); /** * Set a measurement without unit. When setting the measurement without the unit, no formatting @@ -260,7 +260,7 @@ ISpan startChild( @ApiStatus.Internal boolean isNoOp(); - void setContext(@NotNull String key, @NotNull Object context); + void setContext(@Nullable String key, @Nullable Object context); @NotNull Contexts getContexts(); diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index d3e0b010c39..9b39ce77a6d 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -106,16 +106,16 @@ public void setFingerprint(@NotNull List fingerprint) {} public void clearBreadcrumbs() {} @Override - public void setTag(@NotNull String key, @NotNull String value) {} + public void setTag(@Nullable String key, @Nullable String value) {} @Override - public void removeTag(@NotNull String key) {} + public void removeTag(@Nullable String key) {} @Override - public void setExtra(@NotNull String key, @NotNull String value) {} + public void setExtra(@Nullable String key, @Nullable String value) {} @Override - public void removeExtra(@NotNull String key) {} + public void removeExtra(@Nullable String key) {} @Override public @NotNull SentryId getLastEventId() { diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index d5c1b56d8cf..d996fa29d59 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -131,10 +131,10 @@ public void clear() {} } @Override - public void setTag(@NotNull String key, @NotNull String value) {} + public void setTag(@Nullable String key, @Nullable String value) {} @Override - public void removeTag(@NotNull String key) {} + public void removeTag(@Nullable String key) {} @ApiStatus.Internal @Override @@ -143,10 +143,10 @@ public void removeTag(@NotNull String key) {} } @Override - public void setExtra(@NotNull String key, @NotNull String value) {} + public void setExtra(@Nullable String key, @Nullable String value) {} @Override - public void removeExtra(@NotNull String key) {} + public void removeExtra(@Nullable String key) {} @Override public @NotNull Contexts getContexts() { @@ -154,28 +154,28 @@ public void removeExtra(@NotNull String key) {} } @Override - public void setContexts(@NotNull String key, @NotNull Object value) {} + public void setContexts(@Nullable String key, @Nullable Object value) {} @Override - public void setContexts(@NotNull String key, @NotNull Boolean value) {} + public void setContexts(@Nullable String key, @Nullable Boolean value) {} @Override - public void setContexts(@NotNull String key, @NotNull String value) {} + public void setContexts(@Nullable String key, @Nullable String value) {} @Override - public void setContexts(@NotNull String key, @NotNull Number value) {} + public void setContexts(@Nullable String key, @Nullable Number value) {} @Override - public void setContexts(@NotNull String key, @NotNull Collection value) {} + public void setContexts(@Nullable String key, @Nullable Collection value) {} @Override - public void setContexts(@NotNull String key, @NotNull Object[] value) {} + public void setContexts(@Nullable String key, @Nullable Object[] value) {} @Override - public void setContexts(@NotNull String key, @NotNull Character value) {} + public void setContexts(@Nullable String key, @Nullable Character value) {} @Override - public void removeContexts(@NotNull String key) {} + public void removeContexts(@Nullable String key) {} @ApiStatus.Internal @Override diff --git a/sentry/src/main/java/io/sentry/NoOpScopes.java b/sentry/src/main/java/io/sentry/NoOpScopes.java index 8255569387d..a37a730b74c 100644 --- a/sentry/src/main/java/io/sentry/NoOpScopes.java +++ b/sentry/src/main/java/io/sentry/NoOpScopes.java @@ -101,16 +101,16 @@ public void setFingerprint(@NotNull List fingerprint) {} public void clearBreadcrumbs() {} @Override - public void setTag(@NotNull String key, @NotNull String value) {} + public void setTag(@Nullable String key, @Nullable String value) {} @Override - public void removeTag(@NotNull String key) {} + public void removeTag(@Nullable String key) {} @Override - public void setExtra(@NotNull String key, @NotNull String value) {} + public void setExtra(@Nullable String key, @Nullable String value) {} @Override - public void removeExtra(@NotNull String key) {} + public void removeExtra(@Nullable String key) {} @Override public @NotNull SentryId getLastEventId() { diff --git a/sentry/src/main/java/io/sentry/NoOpSpan.java b/sentry/src/main/java/io/sentry/NoOpSpan.java index ef96d23de66..669e28cfcfa 100644 --- a/sentry/src/main/java/io/sentry/NoOpSpan.java +++ b/sentry/src/main/java/io/sentry/NoOpSpan.java @@ -120,10 +120,10 @@ public void setThrowable(@Nullable Throwable throwable) {} } @Override - public void setTag(@NotNull String key, @NotNull String value) {} + public void setTag(@Nullable String key, @Nullable String value) {} @Override - public @Nullable String getTag(@NotNull String key) { + public @Nullable String getTag(@Nullable String key) { return null; } @@ -133,10 +133,10 @@ public boolean isFinished() { } @Override - public void setData(@NotNull String key, @NotNull Object value) {} + public void setData(@Nullable String key, @Nullable Object value) {} @Override - public @Nullable Object getData(@NotNull String key) { + public @Nullable Object getData(@Nullable String key) { return null; } @@ -168,7 +168,7 @@ public boolean isNoOp() { } @Override - public void setContext(@NotNull String key, @NotNull Object context) {} + public void setContext(@Nullable String key, @Nullable Object context) {} @Override public @NotNull Contexts getContexts() { diff --git a/sentry/src/main/java/io/sentry/NoOpTransaction.java b/sentry/src/main/java/io/sentry/NoOpTransaction.java index 963ffac8f57..4d266d1952a 100644 --- a/sentry/src/main/java/io/sentry/NoOpTransaction.java +++ b/sentry/src/main/java/io/sentry/NoOpTransaction.java @@ -185,10 +185,10 @@ public void setThrowable(@Nullable Throwable throwable) {} } @Override - public void setTag(@NotNull String key, @NotNull String value) {} + public void setTag(@Nullable String key, @Nullable String value) {} @Override - public @Nullable String getTag(@NotNull String key) { + public @Nullable String getTag(@Nullable String key) { return null; } @@ -208,10 +208,10 @@ public void setTag(@NotNull String key, @NotNull String value) {} } @Override - public void setData(@NotNull String key, @NotNull Object value) {} + public void setData(@Nullable String key, @Nullable Object value) {} @Override - public @Nullable Object getData(@NotNull String key) { + public @Nullable Object getData(@Nullable String key) { return null; } @@ -224,7 +224,7 @@ public void setMeasurement( @ApiStatus.Internal @Override - public void setContext(@NotNull String key, @NotNull Object context) {} + public void setContext(@Nullable String key, @Nullable Object context) {} @ApiStatus.Internal @Override diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 4dacc8e4dac..a3046dad28c 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -570,12 +570,19 @@ public void clear() { * @param value the value */ @Override - public void setTag(final @NotNull String key, final @NotNull String value) { - this.tags.put(key, value); + public void setTag(final @Nullable String key, final @Nullable String value) { + if (key == null) { + return; + } + if (value == null) { + removeTag(key); + } else { + this.tags.put(key, value); - for (final IScopeObserver observer : options.getScopeObservers()) { - observer.setTag(key, value); - observer.setTags(tags); + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setTag(key, value); + observer.setTags(tags); + } } } @@ -585,7 +592,10 @@ public void setTag(final @NotNull String key, final @NotNull String value) { * @param key the key */ @Override - public void removeTag(final @NotNull String key) { + public void removeTag(final @Nullable String key) { + if (key == null) { + return; + } this.tags.remove(key); for (final IScopeObserver observer : options.getScopeObservers()) { @@ -613,12 +623,19 @@ public Map getExtras() { * @param value the value */ @Override - public void setExtra(final @NotNull String key, final @NotNull String value) { - this.extra.put(key, value); + public void setExtra(final @Nullable String key, final @Nullable String value) { + if (key == null) { + return; + } + if (value == null) { + removeExtra(key); + } else { + this.extra.put(key, value); - for (final IScopeObserver observer : options.getScopeObservers()) { - observer.setExtra(key, value); - observer.setExtras(extra); + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setExtra(key, value); + observer.setExtras(extra); + } } } @@ -628,7 +645,10 @@ public void setExtra(final @NotNull String key, final @NotNull String value) { * @param key the key */ @Override - public void removeExtra(final @NotNull String key) { + public void removeExtra(final @Nullable String key) { + if (key == null) { + return; + } this.extra.remove(key); for (final IScopeObserver observer : options.getScopeObservers()) { @@ -654,7 +674,10 @@ public void removeExtra(final @NotNull String key) { * @param value the context value */ @Override - public void setContexts(final @NotNull String key, final @NotNull Object value) { + public void setContexts(final @Nullable String key, final @Nullable Object value) { + if (key == null) { + return; + } this.contexts.put(key, value); for (final IScopeObserver observer : options.getScopeObservers()) { @@ -669,10 +692,18 @@ public void setContexts(final @NotNull String key, final @NotNull Object value) * @param value the context value */ @Override - public void setContexts(final @NotNull String key, final @NotNull Boolean value) { - final Map map = new HashMap<>(); - map.put("value", value); - setContexts(key, map); + public void setContexts(final @Nullable String key, final @Nullable Boolean value) { + if (key == null) { + return; + } + if (value == null) { + // unset + setContexts(key, (Object) null); + } else { + final Map map = new HashMap<>(); + map.put("value", value); + setContexts(key, map); + } } /** @@ -682,10 +713,18 @@ public void setContexts(final @NotNull String key, final @NotNull Boolean value) * @param value the context value */ @Override - public void setContexts(final @NotNull String key, final @NotNull String value) { - final Map map = new HashMap<>(); - map.put("value", value); - setContexts(key, map); + public void setContexts(final @Nullable String key, final @Nullable String value) { + if (key == null) { + return; + } + if (value == null) { + // unset + setContexts(key, (Object) null); + } else { + final Map map = new HashMap<>(); + map.put("value", value); + setContexts(key, map); + } } /** @@ -695,10 +734,18 @@ public void setContexts(final @NotNull String key, final @NotNull String value) * @param value the context value */ @Override - public void setContexts(final @NotNull String key, final @NotNull Number value) { - final Map map = new HashMap<>(); - map.put("value", value); - setContexts(key, map); + public void setContexts(final @Nullable String key, final @Nullable Number value) { + if (key == null) { + return; + } + if (value == null) { + // unset + setContexts(key, (Object) null); + } else { + final Map map = new HashMap<>(); + map.put("value", value); + setContexts(key, map); + } } /** @@ -708,10 +755,18 @@ public void setContexts(final @NotNull String key, final @NotNull Number value) * @param value the context value */ @Override - public void setContexts(final @NotNull String key, final @NotNull Collection value) { - final Map> map = new HashMap<>(); - map.put("value", value); - setContexts(key, map); + public void setContexts(final @Nullable String key, final @Nullable Collection value) { + if (key == null) { + return; + } + if (value == null) { + // unset + setContexts(key, (Object) null); + } else { + final Map> map = new HashMap<>(); + map.put("value", value); + setContexts(key, map); + } } /** @@ -721,10 +776,18 @@ public void setContexts(final @NotNull String key, final @NotNull Collection * @param value the context value */ @Override - public void setContexts(final @NotNull String key, final @NotNull Object[] value) { - final Map map = new HashMap<>(); - map.put("value", value); - setContexts(key, map); + public void setContexts(final @Nullable String key, final @Nullable Object[] value) { + if (key == null) { + return; + } + if (value == null) { + // unset + setContexts(key, (Object) null); + } else { + final Map map = new HashMap<>(); + map.put("value", value); + setContexts(key, map); + } } /** @@ -734,10 +797,18 @@ public void setContexts(final @NotNull String key, final @NotNull Object[] value * @param value the context value */ @Override - public void setContexts(final @NotNull String key, final @NotNull Character value) { - final Map map = new HashMap<>(); - map.put("value", value); - setContexts(key, map); + public void setContexts(final @Nullable String key, final @Nullable Character value) { + if (key == null) { + return; + } + if (value == null) { + // unset + setContexts(key, (Object) null); + } else { + final Map map = new HashMap<>(); + map.put("value", value); + setContexts(key, map); + } } /** @@ -746,7 +817,10 @@ public void setContexts(final @NotNull String key, final @NotNull Character valu * @param key the Key */ @Override - public void removeContexts(final @NotNull String key) { + public void removeContexts(final @Nullable String key) { + if (key == null) { + return; + } contexts.remove(key); } diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 14025a1d774..92e146b9e46 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -514,7 +514,7 @@ public void clearBreadcrumbs() { } @Override - public void setTag(final @NotNull String key, final @NotNull String value) { + public void setTag(final @Nullable String key, final @Nullable String value) { if (!isEnabled()) { getOptions() .getLogger() @@ -527,7 +527,7 @@ public void setTag(final @NotNull String key, final @NotNull String value) { } @Override - public void removeTag(final @NotNull String key) { + public void removeTag(final @Nullable String key) { if (!isEnabled()) { getOptions() .getLogger() @@ -540,7 +540,7 @@ public void removeTag(final @NotNull String key) { } @Override - public void setExtra(final @NotNull String key, final @NotNull String value) { + public void setExtra(final @Nullable String key, final @Nullable String value) { if (!isEnabled()) { getOptions() .getLogger() @@ -553,7 +553,7 @@ public void setExtra(final @NotNull String key, final @NotNull String value) { } @Override - public void removeExtra(final @NotNull String key) { + public void removeExtra(final @Nullable String key) { if (!isEnabled()) { getOptions() .getLogger() diff --git a/sentry/src/main/java/io/sentry/ScopesAdapter.java b/sentry/src/main/java/io/sentry/ScopesAdapter.java index 6df6deee3d4..d5d143af52b 100644 --- a/sentry/src/main/java/io/sentry/ScopesAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopesAdapter.java @@ -124,22 +124,22 @@ public void clearBreadcrumbs() { } @Override - public void setTag(@NotNull String key, @NotNull String value) { + public void setTag(@Nullable String key, @Nullable String value) { Sentry.setTag(key, value); } @Override - public void removeTag(@NotNull String key) { + public void removeTag(@Nullable String key) { Sentry.removeTag(key); } @Override - public void setExtra(@NotNull String key, @NotNull String value) { + public void setExtra(@Nullable String key, @Nullable String value) { Sentry.setExtra(key, value); } @Override - public void removeExtra(@NotNull String key) { + public void removeExtra(@Nullable String key) { Sentry.removeExtra(key); } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index bd5f296b7c2..822609b2779 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -877,7 +877,7 @@ public static void clearBreadcrumbs() { * @param key the key * @param value the value */ - public static void setTag(final @NotNull String key, final @NotNull String value) { + public static void setTag(final @Nullable String key, final @Nullable String value) { getCurrentScopes().setTag(key, value); } @@ -886,7 +886,7 @@ public static void setTag(final @NotNull String key, final @NotNull String value * * @param key the key */ - public static void removeTag(final @NotNull String key) { + public static void removeTag(final @Nullable String key) { getCurrentScopes().removeTag(key); } @@ -897,7 +897,7 @@ public static void removeTag(final @NotNull String key) { * @param key the key * @param value the value */ - public static void setExtra(final @NotNull String key, final @NotNull String value) { + public static void setExtra(final @Nullable String key, final @Nullable String value) { getCurrentScopes().setExtra(key, value); } @@ -906,7 +906,7 @@ public static void setExtra(final @NotNull String key, final @NotNull String val * * @param key the key */ - public static void removeExtra(final @NotNull String key) { + public static void removeExtra(final @Nullable String key) { getCurrentScopes().removeExtra(key); } diff --git a/sentry/src/main/java/io/sentry/SentryBaseEvent.java b/sentry/src/main/java/io/sentry/SentryBaseEvent.java index 58435194a7b..74836c69573 100644 --- a/sentry/src/main/java/io/sentry/SentryBaseEvent.java +++ b/sentry/src/main/java/io/sentry/SentryBaseEvent.java @@ -198,24 +198,31 @@ public void setTags(@Nullable Map tags) { this.tags = CollectionUtils.newHashMap(tags); } - public void removeTag(@NotNull String key) { - if (tags != null) { + public void removeTag(@Nullable String key) { + if (tags != null && key != null) { tags.remove(key); } } - public @Nullable String getTag(final @NotNull String key) { - if (tags != null) { + public @Nullable String getTag(final @Nullable String key) { + if (tags != null && key != null) { return tags.get(key); } return null; } - public void setTag(final @NotNull String key, final @NotNull String value) { + public void setTag(final @Nullable String key, final @Nullable String value) { if (tags == null) { tags = new HashMap<>(); } - tags.put(key, value); + if (key == null) { + return; + } + if (value == null) { + removeTag(key); + } else { + tags.put(key, value); + } } public @Nullable String getRelease() { @@ -298,21 +305,28 @@ public void setExtras(final @Nullable Map extra) { this.extra = CollectionUtils.newHashMap(extra); } - public void setExtra(final @NotNull String key, final @NotNull Object value) { + public void setExtra(final @Nullable String key, final @Nullable Object value) { if (extra == null) { extra = new HashMap<>(); } - extra.put(key, value); + if (key == null) { + return; + } + if (value == null) { + removeExtra(key); + } else { + extra.put(key, value); + } } - public void removeExtra(final @NotNull String key) { - if (extra != null) { + public void removeExtra(final @Nullable String key) { + if (extra != null && key != null) { extra.remove(key); } } - public @Nullable Object getExtra(final @NotNull String key) { - if (extra != null) { + public @Nullable Object getExtra(final @Nullable String key) { + if (extra != null && key != null) { return extra.get(key); } return null; diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index b2a785faf76..b82a25cb2f7 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -1504,8 +1504,15 @@ public void setEnableExternalConfiguration(boolean enableExternalConfiguration) * @param key the key * @param value the value */ - public void setTag(final @NotNull String key, final @NotNull String value) { - this.tags.put(key, value); + public void setTag(final @Nullable String key, final @Nullable String value) { + if (key == null) { + return; + } + if (value == null) { + this.tags.remove(key); + } else { + this.tags.put(key, value); + } } /** diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 36329db7a71..cc832a136ef 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -777,7 +777,7 @@ public void setThrowable(final @Nullable Throwable throwable) { } @Override - public void setTag(final @NotNull String key, final @NotNull String value) { + public void setTag(final @Nullable String key, final @Nullable String value) { if (root.isFinished()) { scopes .getOptions() @@ -790,7 +790,7 @@ public void setTag(final @NotNull String key, final @NotNull String value) { } @Override - public @Nullable String getTag(final @NotNull String key) { + public @Nullable String getTag(final @Nullable String key) { return this.root.getTag(key); } @@ -800,7 +800,7 @@ public boolean isFinished() { } @Override - public void setData(@NotNull String key, @NotNull Object value) { + public void setData(@Nullable String key, @Nullable Object value) { if (root.isFinished()) { scopes .getOptions() @@ -814,7 +814,7 @@ public void setData(@NotNull String key, @NotNull Object value) { } @Override - public @Nullable Object getData(@NotNull String key) { + public @Nullable Object getData(@Nullable String key) { return this.root.getData(key); } @@ -973,7 +973,7 @@ AtomicBoolean isDeadlineTimerRunning() { @ApiStatus.Internal @Override - public void setContext(final @NotNull String key, final @NotNull Object context) { + public void setContext(final @Nullable String key, final @Nullable Object context) { contexts.put(key, context); } diff --git a/sentry/src/main/java/io/sentry/Span.java b/sentry/src/main/java/io/sentry/Span.java index 3f08cca2a58..d3eb2c06551 100644 --- a/sentry/src/main/java/io/sentry/Span.java +++ b/sentry/src/main/java/io/sentry/Span.java @@ -268,12 +268,15 @@ public void setStatus(final @Nullable SpanStatus status) { } @Override - public void setTag(final @NotNull String key, final @NotNull String value) { + public void setTag(final @Nullable String key, final @Nullable String value) { this.context.setTag(key, value); } @Override - public @Nullable String getTag(@NotNull String key) { + public @Nullable String getTag(@Nullable String key) { + if (key == null) { + return null; + } return context.getTags().get(key); } @@ -328,12 +331,22 @@ public Map getTags() { } @Override - public void setData(final @NotNull String key, final @NotNull Object value) { - data.put(key, value); + public void setData(final @Nullable String key, final @Nullable Object value) { + if (key == null) { + return; + } + if (value == null) { + data.remove(key); + } else { + data.put(key, value); + } } @Override - public @Nullable Object getData(final @NotNull String key) { + public @Nullable Object getData(final @Nullable String key) { + if (key == null) { + return null; + } return data.get(key); } @@ -400,7 +413,7 @@ public boolean isNoOp() { } @Override - public void setContext(@NotNull String key, @NotNull Object context) { + public void setContext(@Nullable String key, @Nullable Object context) { this.contexts.put(key, context); } diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java index 6f1e4e4eaf9..05f9aa25a5a 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -120,10 +120,15 @@ public void setOperation(final @NotNull String operation) { this.op = Objects.requireNonNull(operation, "operation is required"); } - public void setTag(final @NotNull String name, final @NotNull String value) { - Objects.requireNonNull(name, "name is required"); - Objects.requireNonNull(value, "value is required"); - this.tags.put(name, value); + public void setTag(final @Nullable String name, final @Nullable String value) { + if (name == null) { + return; + } + if (value == null) { + this.tags.remove(name); + } else { + this.tags.put(name, value); + } } public void setDescription(final @Nullable String description) { @@ -238,8 +243,15 @@ public void setInstrumenter(final @NotNull Instrumenter instrumenter) { return data; } - public void setData(final @NotNull String key, final @NotNull Object value) { - data.put(key, value); + public void setData(final @Nullable String key, final @Nullable Object value) { + if (key == null) { + return; + } + if (value == null) { + data.remove(key); + } else { + data.put(key, value); + } } @ApiStatus.Internal diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 3705452399c..123436cfae4 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -15,6 +15,7 @@ import java.io.IOException; import java.util.Collections; import java.util.Enumeration; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -172,23 +173,39 @@ public boolean isEmpty() { return internalStorage.isEmpty(); } - public boolean containsKey(final @NotNull Object key) { + public boolean containsKey(final @Nullable Object key) { + if (key == null) { + return false; + } return internalStorage.containsKey(key); } - public @Nullable Object get(final @NotNull Object key) { + public @Nullable Object get(final @Nullable Object key) { + if (key == null) { + return null; + } return internalStorage.get(key); } - public @Nullable Object put(final @NotNull String key, final @Nullable Object value) { - return internalStorage.put(key, value); + public @Nullable Object put(final @Nullable String key, final @Nullable Object value) { + if (key == null) { + return null; + } + if (value == null) { + return internalStorage.remove(key); + } else { + return internalStorage.put(key, value); + } } - public @Nullable Object set(final @NotNull String key, final @Nullable Object value) { + public @Nullable Object set(final @Nullable String key, final @Nullable Object value) { return put(key, value); } - public @Nullable Object remove(final @NotNull Object key) { + public @Nullable Object remove(final @Nullable Object key) { + if (key == null) { + return null; + } return internalStorage.remove(key); } @@ -200,16 +217,31 @@ public boolean containsKey(final @NotNull Object key) { return internalStorage.entrySet(); } - public void putAll(Map m) { - internalStorage.putAll(m); + public void putAll(final @Nullable Map m) { + if (m == null) { + return; + } + + final @NotNull Map tmpMap = new HashMap<>(); + + for (final @NotNull Map.Entry entry : m.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + tmpMap.put(entry.getKey(), entry.getValue()); + } + } + + internalStorage.putAll(tmpMap); } - public void putAll(final @NotNull Contexts contexts) { + public void putAll(final @Nullable Contexts contexts) { + if (contexts == null) { + return; + } internalStorage.putAll(contexts.internalStorage); } @Override - public boolean equals(Object obj) { + public boolean equals(final @Nullable Object obj) { if (obj != null && obj instanceof Contexts) { final @NotNull Contexts otherContexts = (Contexts) obj; return internalStorage.equals(otherContexts.internalStorage); diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index 658e41149bc..2c59869b7cc 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -7,6 +7,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertTrue class BreadcrumbTest { @@ -273,6 +274,44 @@ class BreadcrumbTest { assertNull(breadcrumb.data["name"]) } + @Test + fun `null key data does not throw`() { + val breadcrumb = Breadcrumb() + breadcrumb.setData(null, "v") + assertNull(breadcrumb.getData(null)) + } + + @Test + fun `null key and value data does not throw`() { + val breadcrumb = Breadcrumb() + breadcrumb.setData(null, null) + assertNull(breadcrumb.getData(null)) + assertTrue(breadcrumb.data.isEmpty()) + } + + @Test + fun `null value data does not throw`() { + val breadcrumb = Breadcrumb() + breadcrumb.setData("k", null) + assertNull(breadcrumb.getData("k")) + assertTrue(breadcrumb.data.isEmpty()) + } + + @Test + fun `set null value data removes previous entry`() { + val breadcrumb = Breadcrumb() + breadcrumb.setData("k", "v") + breadcrumb.setData("k", null) + assertNull(breadcrumb.getData("k")) + assertTrue(breadcrumb.data.isEmpty()) + } + + @Test + fun `remove null key data does not throw`() { + val breadcrumb = Breadcrumb() + breadcrumb.removeData(null) + } + class TestKey(val id: Long) { override fun toString(): String { return id.toString() diff --git a/sentry/src/test/java/io/sentry/CombinedContextsViewTest.kt b/sentry/src/test/java/io/sentry/CombinedContextsViewTest.kt index 2d8d04f22f9..c77ca99cb9d 100644 --- a/sentry/src/test/java/io/sentry/CombinedContextsViewTest.kt +++ b/sentry/src/test/java/io/sentry/CombinedContextsViewTest.kt @@ -604,4 +604,31 @@ class CombinedContextsViewTest { assertNull(fixture.isolation.get("test")) assertEquals("global", fixture.global.get("test")) } + + @Test + fun `set null value on context does not cause exception`() { + val combined = fixture.getSut() + combined.set("k", null) + assertFalse(combined.containsKey("k")) + } + + @Test + fun `set null key on context does not cause exception`() { + val combined = fixture.getSut() + combined.set(null, "v") + assertFalse(combined.containsKey(null)) + } + + @Test + fun `set null key and value on context does not cause exception`() { + val combined = fixture.getSut() + combined.set(null, null) + assertFalse(combined.containsKey(null)) + } + + @Test + fun `remove null key from context does not cause exception`() { + val combined = fixture.getSut() + combined.remove(null) + } } diff --git a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt index cbb8ed0e9cd..10ae3741cf8 100644 --- a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt +++ b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt @@ -1141,6 +1141,63 @@ class CombinedScopeViewTest { assertEquals(SentryId.EMPTY_ID, fixture.globalScope.replayId) } + @Test + fun `null tags do not cause NPE`() { + val scope = fixture.getSut() + scope.setTag("k", "oldvalue") + scope.setTag(null, null) + scope.setTag("k", null) + scope.setTag(null, "v") + scope.removeTag(null) + kotlin.test.assertTrue(scope.tags.isEmpty()) + } + + @Test + fun `null extras do not cause NPE`() { + val scope = fixture.getSut() + scope.setExtra("k", "oldvalue") + scope.setExtra(null, null) + scope.setExtra("k", null) + scope.setExtra(null, "v") + scope.removeExtra(null) + kotlin.test.assertTrue(scope.extras.isEmpty()) + } + + @Test + fun `null contexts do not cause NPE`() { + val scope = fixture.getSut() + + scope.setContexts("obj", null as Any?) + scope.setContexts("bool", true) + scope.setContexts("string", "hello") + scope.setContexts("num", 100) + scope.setContexts("list", listOf("a", "b")) + scope.setContexts("array", arrayOf("c", "d")) + scope.setContexts("char", 'z') + + kotlin.test.assertFalse(scope.contexts.isEmpty) + + scope.setContexts(null, null as Any?) + scope.setContexts(null, null as Boolean?) + scope.setContexts(null, null as String?) + scope.setContexts(null, null as Number?) + scope.setContexts(null, null as List?) + scope.setContexts(null, null as Array?) + scope.setContexts(null, null as Character?) + + scope.setContexts("obj", null as Any?) + scope.setContexts("bool", null as Boolean?) + scope.setContexts("string", null as String?) + scope.setContexts("num", null as Number?) + scope.setContexts("list", null as List?) + scope.setContexts("array", null as Array?) + scope.setContexts("char", null as Character?) + + scope.removeContexts(null) + + kotlin.test.assertTrue(scope.contexts.isEmpty) + } + private fun createTransaction(name: String, scopes: Scopes? = null): ITransaction { val scopesToUse = scopes ?: fixture.scopes return SentryTracer(TransactionContext(name, "op", TracesSamplingDecision(true)), scopesToUse).also { diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index b8025735e8a..6f29ff54b80 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -1040,6 +1040,63 @@ class ScopeTest { // previously was crashing, see https://github.com/getsentry/sentry-java/issues/3313 } + @Test + fun `null tags do not cause NPE`() { + val scope = Scope(SentryOptions.empty()) + scope.setTag("k", "oldvalue") + scope.setTag(null, null) + scope.setTag("k", null) + scope.setTag(null, "v") + scope.removeTag(null) + assertTrue(scope.tags.isEmpty()) + } + + @Test + fun `null extras do not cause NPE`() { + val scope = Scope(SentryOptions.empty()) + scope.setExtra("k", "oldvalue") + scope.setExtra(null, null) + scope.setExtra("k", null) + scope.setExtra(null, "v") + scope.removeExtra(null) + assertTrue(scope.extras.isEmpty()) + } + + @Test + fun `null contexts do not cause NPE`() { + val scope = Scope(SentryOptions.empty()) + + scope.setContexts("obj", null as Any?) + scope.setContexts("bool", true) + scope.setContexts("string", "hello") + scope.setContexts("num", 100) + scope.setContexts("list", listOf("a", "b")) + scope.setContexts("array", arrayOf("c", "d")) + scope.setContexts("char", 'z') + + assertFalse(scope.contexts.isEmpty) + + scope.setContexts(null, null as Any?) + scope.setContexts(null, null as Boolean?) + scope.setContexts(null, null as String?) + scope.setContexts(null, null as Number?) + scope.setContexts(null, null as List?) + scope.setContexts(null, null as Array?) + scope.setContexts(null, null as Character?) + + scope.setContexts("obj", null as Any?) + scope.setContexts("bool", null as Boolean?) + scope.setContexts("string", null as String?) + scope.setContexts("num", null as Number?) + scope.setContexts("list", null as List?) + scope.setContexts("array", null as Array?) + scope.setContexts("char", null as Character?) + + scope.removeContexts(null) + + assertTrue(scope.contexts.isEmpty) + } + private fun eventProcessor(): EventProcessor { return object : EventProcessor { override fun process(event: SentryEvent, hint: Hint): SentryEvent? { diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index fdbbf61b058..23d2dcdd94a 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -2141,6 +2141,30 @@ class ScopesTest { assertEquals("other.span.origin", transaction.spanContext.origin) } + @Test + fun `null tags do not cause NPE`() { + val scopes = generateScopes() + scopes.setTag(null, null) + scopes.setTag("k", null) + scopes.setTag(null, "v") + scopes.removeTag(null) + assertTrue(scopes.scope.tags.isEmpty()) + assertTrue(scopes.isolationScope.tags.isEmpty()) + assertTrue(scopes.globalScope.tags.isEmpty()) + } + + @Test + fun `null extras do not cause NPE`() { + val scopes = generateScopes() + scopes.setExtra(null, null) + scopes.setExtra("k", null) + scopes.setExtra(null, "v") + scopes.removeExtra(null) + assertTrue(scopes.scope.extras.isEmpty()) + assertTrue(scopes.isolationScope.extras.isEmpty()) + assertTrue(scopes.globalScope.extras.isEmpty()) + } + private val dsnTest = "https://key@sentry.io/proj" private fun generateScopes(optionsConfiguration: Sentry.OptionsConfiguration? = null): IScopes { diff --git a/sentry/src/test/java/io/sentry/SentryEventTest.kt b/sentry/src/test/java/io/sentry/SentryEventTest.kt index 6ad48dfdc0c..9b933884500 100644 --- a/sentry/src/test/java/io/sentry/SentryEventTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEventTest.kt @@ -12,6 +12,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class SentryEventTest { @@ -157,4 +158,32 @@ class SentryEventTest { assertEquals(mapOf("key1" to "value1", "key2" to "value2", "key3" to "value3"), it) } } + + @Test + fun `null tag does not cause NPE`() { + val event = SentryEvent() + + event.setTag("k", "oldvalue") + event.setTag(null, null) + event.setTag("k", null) + event.setTag(null, "v") + + assertNull(event.getTag(null)) + assertNull(event.getTag("k")) + assertFalse(event.tags!!.containsKey("k")) + } + + @Test + fun `null extra does not cause NPE`() { + val event = SentryEvent() + + event.setExtra("k", "oldvalue") + event.setExtra(null, null) + event.setExtra("k", null) + event.setExtra(null, "v") + + assertNull(event.getExtra(null)) + assertNull(event.getExtra("k")) + assertFalse(event.extras!!.containsKey("k")) + } } diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index e2f4692357c..c2ae7527d8d 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -695,4 +695,13 @@ class SentryOptionsTest { options.merge(externalOptions) assertEquals(listOf(FilterString("checkin1"), FilterString("checkin2")), options.ignoredCheckIns) } + + @Test + fun `null tag`() { + val options = SentryOptions.empty() + options.setTag("k", "v") + options.setTag("k", null) + options.setTag(null, null) + assertTrue(options.tags.isEmpty()) + } } diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index eb333187b29..8c8bd323c82 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -1410,4 +1410,27 @@ class SentryTracerTest { assertNull(transaction.finishDate) transaction.finish() } + + @Test + fun `setting null data does not cause NPE`() { + val transaction = fixture.getSut() + transaction.setData("k", "oldvalue") + transaction.setData(null, null) + transaction.setData("k", null) + transaction.setData(null, "v") + assertNull(transaction.getData(null)) + assertNull(transaction.getData("k")) + assertFalse(transaction.data!!.containsKey("k")) + } + + @Test + fun `setting null tag does not cause NPE`() { + val transaction = fixture.getSut() + transaction.setTag("k", "oldvalue") + transaction.setTag(null, null) + transaction.setTag("k", null) + transaction.setTag(null, "v") + assertNull(transaction.getTag(null)) + assertNull(transaction.getTag("k")) + } } diff --git a/sentry/src/test/java/io/sentry/SpanContextTest.kt b/sentry/src/test/java/io/sentry/SpanContextTest.kt index 0935c10e1f1..bbbb72a0f05 100644 --- a/sentry/src/test/java/io/sentry/SpanContextTest.kt +++ b/sentry/src/test/java/io/sentry/SpanContextTest.kt @@ -3,6 +3,7 @@ package io.sentry import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue class SpanContextTest { @@ -30,4 +31,22 @@ class SpanContextTest { assertEquals("0.1", trace.baggage?.sampleRate) assertEquals("0.2", trace.baggage?.sampleRand) } + + @Test + fun `null tag`() { + val trace = SpanContext("op") + trace.setTag("k", "v") + trace.setTag("k", null) + trace.setTag(null, null) + assertTrue(trace.tags.isEmpty()) + } + + @Test + fun `null data`() { + val trace = SpanContext("op") + trace.setData("k", "v") + trace.setData("k", null) + trace.setData(null, null) + assertTrue(trace.data.isEmpty()) + } } diff --git a/sentry/src/test/java/io/sentry/SpanTest.kt b/sentry/src/test/java/io/sentry/SpanTest.kt index 79c374413c0..80c72700edf 100644 --- a/sentry/src/test/java/io/sentry/SpanTest.kt +++ b/sentry/src/test/java/io/sentry/SpanTest.kt @@ -547,6 +547,39 @@ class SpanTest { span.finish() } + @Test + fun `null data`() { + val span = fixture.getSut() + span.setData("k", "v") + span.setData("k", null) + span.setData(null, null) + assertNull(span.getData("k")) + assertNull(span.getData(null)) + assertTrue(span.data.isEmpty()) + } + + @Test + fun `null tag`() { + val span = fixture.getSut() + span.setTag("k", "v") + span.setTag("k", null) + span.setTag(null, null) + assertNull(span.getTag("k")) + assertNull(span.getTag(null)) + assertTrue(span.tags.isEmpty()) + } + + @Test + fun `null context`() { + val span = fixture.getSut() + span.setContext("k", "v") + span.setContext("k", null) + span.setContext(null, null) + assertNull(span.contexts.get("k")) + assertNull(span.contexts.get(null)) + assertTrue(span.contexts.isEmpty) + } + private fun getTransaction(transactionContext: TransactionContext = TransactionContext("name", "op")): SentryTracer { return SentryTracer(transactionContext, fixture.scopes) } diff --git a/sentry/src/test/java/io/sentry/protocol/ContextsTest.kt b/sentry/src/test/java/io/sentry/protocol/ContextsTest.kt index 1b422ed9af6..e1ffe73c0cd 100644 --- a/sentry/src/test/java/io/sentry/protocol/ContextsTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/ContextsTest.kt @@ -3,6 +3,7 @@ package io.sentry.protocol import io.sentry.SpanContext import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNotSame @@ -50,4 +51,87 @@ class ContextsTest { assertEquals(contexts["some-property"], clone["some-property"]) assertEquals(contexts.trace!!.description, clone.trace!!.description) } + + @Test + fun `set null value on context does not cause exception`() { + val contexts = Contexts() + contexts.set("k", null) + assertFalse(contexts.containsKey("k")) + } + + @Test + fun `set null key on context does not cause exception`() { + val contexts = Contexts() + contexts.set(null, "v") + assertFalse(contexts.containsKey(null)) + } + + @Test + fun `set null key and value on context does not cause exception`() { + val contexts = Contexts() + contexts.set(null, null) + assertFalse(contexts.containsKey(null)) + } + + @Test + fun `put null value on context does not cause exception`() { + val contexts = Contexts() + contexts.put("k", null) + assertFalse(contexts.containsKey("k")) + } + + @Test + fun `put null value on context removes previous value`() { + val contexts = Contexts() + contexts.put("k", "v") + contexts.put("k", null) + assertFalse(contexts.containsKey("k")) + } + + @Test + fun `put null key on context does not cause exception`() { + val contexts = Contexts() + contexts.put(null, "v") + assertFalse(contexts.containsKey(null)) + } + + @Test + fun `put null key and value on context does not cause exception`() { + val contexts = Contexts() + contexts.put(null, null) + assertFalse(contexts.containsKey(null)) + } + + @Test + fun `remove null key from context does not cause exception`() { + val contexts = Contexts() + contexts.remove(null) + } + + @Test + fun `putAll(null) contexts does not throw`() { + val contexts = Contexts() + val nullContexts: Contexts? = null + contexts.putAll(nullContexts) + } + + @Test + fun `putAll(null) map does not throw`() { + val contexts = Contexts() + val nullMap: Map? = null + contexts.putAll(nullMap) + } + + @Test + fun `putAll map with null key and value does not throw`() { + val contexts = Contexts() + val map = mutableMapOf( + null to null, + "k" to null, + "a" to 1 + ) + contexts.putAll(map) + + assertEquals(listOf("a"), contexts.keys().toList()) + } } From 878fd7b411aef94ee58661148f53bc9f4b6c7d80 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Wed, 12 Mar 2025 12:40:15 +0300 Subject: [PATCH 029/914] Fix "class ch.qos.logback.classic.spi.ThrowableProxyVO cannot be cast to class ch.qos.logback.classic.spi.ThrowableProxy" (#4206) * Fix "class ch.qos.logback.classic.spi.ThrowableProxyVO cannot be cast to class ch.qos.logback.classic.spi.ThrowableProxy" 15:44:34,110 |-ERROR in io.sentry.logback.SentryAppender[SENTRY_ORIGINAL] - Appender [SENTRY_ORIGINAL] failed to append. java.lang.ClassCastException: class ch.qos.logback.classic.spi.ThrowableProxyVO cannot be cast to class ch.qos.logback.classic.spi.ThrowableProxy (ch.qos.logback.classic.spi.ThrowableProxyVO and ch.qos.logback.classic.spi.ThrowableProxy are in unnamed module of loader 'app') at java.lang.ClassCastException: class ch.qos.logback.classic.spi.ThrowableProxyVO cannot be cast to class ch.qos.logback.classic.spi.ThrowableProxy (ch.qos.logback.classic.spi.ThrowableProxyVO and ch.qos.logback.classic.spi.ThrowableProxy are in unnamed module of loader 'app') at at io.sentry.logback.SentryAppender.createEvent(SentryAppender.java:113) at at io.sentry.logback.SentryAppender.append(SentryAppender.java:80) at at io.sentry.logback.SentryAppender.append(SentryAppender.java:41) at at ch.qos.logback.core.UnsynchronizedAppenderBase.doAppend(UnsynchronizedAppenderBase.java:85) at at ch.qos.logback.core.spi.AppenderAttachableImpl.appendLoopOnAppenders(AppenderAttachableImpl.java:51) at at io.opentelemetry.instrumentation.logback.mdc.v1_0.OpenTelemetryAppender.append(OpenTelemetryAppender.java:111) at at io.opentelemetry.instrumentation.logback.mdc.v1_0.OpenTelemetryAppender.append(OpenTelemetryAppender.java:30) at at ch.qos.logback.core.UnsynchronizedAppenderBase.doAppend(UnsynchronizedAppenderBase.java:85) at at ch.qos.logback.core.spi.AppenderAttachableImpl.appendLoopOnAppenders(AppenderAttachableImpl.java:51) at at ch.qos.logback.classic.Logger.appendLoopOnAppenders(Logger.java:272) at at ch.qos.logback.classic.Logger.callAppenders(Logger.java:259) at at ch.qos.logback.classic.Logger.buildLoggingEventAndAppend(Logger.java:426) at at ch.qos.logback.classic.Logger.filterAndLog_2(Logger.java:419) at at ch.qos.logback.classic.Logger.error(Logger.java:535) at at io.micronaut.runtime.Micronaut.handleStartupException(Micronaut.java:343) at at io.micronaut.runtime.Micronaut.start(Micronaut.java:173) at at io.micronaut.runtime.Micronaut.run(Micronaut.java:328) at at io.micronaut.runtime.Micronaut.run(Micronaut.java:314) * Spotless * Added changelog. * add test * move and extend changelog entry --------- Co-authored-by: Alexander Dinauer Co-authored-by: Alexander Dinauer --- CHANGELOG.md | 3 +++ .../io/sentry/logback/SentryAppender.java | 18 ++++++++------- .../io/sentry/logback/SentryAppenderTest.kt | 23 +++++++++++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ddff9da8b..7bc5369d987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ - Add support for setting in-app-includes/in-app-excludes via AndroidManifest.xml ([#4240](https://github.com/getsentry/sentry-java/pull/4240)) - Modifications to OkHttp requests are now properly propagated to the affected span / breadcrumbs ([#4238](https://github.com/getsentry/sentry-java/pull/4238)) - Please ensure the SentryOkHttpInterceptor is added last to your OkHttpClient, as otherwise changes to the `Request` by subsequent interceptors won't be considered +- Fix "class ch.qos.logback.classic.spi.ThrowableProxyVO cannot be cast to class ch.qos.logback.classic.spi.ThrowableProxy" ([#4206](https://github.com/getsentry/sentry-java/pull/4206)) + - In this case we cannot report the `Throwable` to Sentry as it's not available + - If you are using OpenTelemetry v1 `OpenTelemetryAppender`, please consider upgrading to v2 ### Features diff --git a/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java b/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java index 77ce05f47f3..614e3dcb0f7 100644 --- a/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java +++ b/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java @@ -110,14 +110,16 @@ protected void append(@NotNull ILoggingEvent eventObject) { event.setLogger(loggingEvent.getLoggerName()); event.setLevel(formatLevel(loggingEvent.getLevel())); - final ThrowableProxy throwableInformation = (ThrowableProxy) loggingEvent.getThrowableProxy(); - if (throwableInformation != null) { - final Mechanism mechanism = new Mechanism(); - mechanism.setType(MECHANISM_TYPE); - final Throwable mechanismException = - new ExceptionMechanismException( - mechanism, throwableInformation.getThrowable(), Thread.currentThread()); - event.setThrowable(mechanismException); + if (loggingEvent.getThrowableProxy() instanceof ThrowableProxy) { + final ThrowableProxy throwableInformation = (ThrowableProxy) loggingEvent.getThrowableProxy(); + if (throwableInformation != null) { + final Mechanism mechanism = new Mechanism(); + mechanism.setType(MECHANISM_TYPE); + final Throwable mechanismException = + new ExceptionMechanismException( + mechanism, throwableInformation.getThrowable(), Thread.currentThread()); + event.setThrowable(mechanismException); + } } if (loggingEvent.getThreadName() != null) { diff --git a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt index 526220d333b..c4971748b10 100644 --- a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt +++ b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt @@ -4,6 +4,9 @@ import ch.qos.logback.classic.Level import ch.qos.logback.classic.LoggerContext import ch.qos.logback.classic.encoder.PatternLayoutEncoder import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.classic.spi.LoggingEvent +import ch.qos.logback.classic.spi.LoggingEventVO +import ch.qos.logback.classic.spi.ThrowableProxy import ch.qos.logback.core.encoder.Encoder import ch.qos.logback.core.encoder.EncoderBase import ch.qos.logback.core.status.Status @@ -536,4 +539,24 @@ class SentryAppenderTest { assertTrue(Sentry.isEnabled()) System.clearProperty("sentry.dsn") } + + @Test + fun `does not crash on ThrowableProxyVO`() { + fixture = Fixture() + val throwableProxy = ThrowableProxy(RuntimeException("hello proxy throwable")) + val loggingEvent = LoggingEvent() + loggingEvent.level = Level.ERROR + loggingEvent.setThrowableProxy(throwableProxy) + val loggingEventVO = LoggingEventVO.build(loggingEvent) + + fixture.appender.append(loggingEventVO) + + verify(fixture.transport).send( + checkEvent { event -> + assertEquals(SentryLevel.ERROR, event.level) + assertNull(event.exceptions) + }, + anyOrNull() + ) + } } From 96835663325fa1d414f1782f4bac85999155fd92 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 13 Mar 2025 09:17:18 +0100 Subject: [PATCH 030/914] Pass OpenTelemetry span attributes into TracesSampler callback (#4253) * Pass OpenTelemetry span attributes into TracesSampler callback * Format code * changelog * api * Apply suggestions from code review Co-authored-by: Lorenzo Cian --------- Co-authored-by: Sentry Github Bot Co-authored-by: Lorenzo Cian --- CHANGELOG.md | 2 + .../sentry/opentelemetry/SentrySampler.java | 30 ++++- sentry/api/sentry.api | 3 +- .../main/java/io/sentry/SamplingContext.java | 16 ++- sentry/src/main/java/io/sentry/Scopes.java | 2 +- sentry/src/main/java/io/sentry/Sentry.java | 3 +- .../test/java/io/sentry/TracesSamplerTest.kt | 108 ++++++++++++++---- 7 files changed, 132 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc5369d987..02fadfe5581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - Fix "class ch.qos.logback.classic.spi.ThrowableProxyVO cannot be cast to class ch.qos.logback.classic.spi.ThrowableProxy" ([#4206](https://github.com/getsentry/sentry-java/pull/4206)) - In this case we cannot report the `Throwable` to Sentry as it's not available - If you are using OpenTelemetry v1 `OpenTelemetryAppender`, please consider upgrading to v2 +- Pass OpenTelemetry span attributes into TracesSampler callback ([#4253](https://github.com/getsentry/sentry-java/pull/4253)) + - `SamplingContext` now has a `getAttribute` method that grants access to OpenTelemetry span attributes via their String key (e.g. `http.request.method`) ### Features diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java index 89499321293..5493ba033cb 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java @@ -23,7 +23,9 @@ import io.sentry.TransactionContext; import io.sentry.clientreport.DiscardReason; import io.sentry.protocol.SentryId; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -64,13 +66,15 @@ public SamplingResult shouldSample( if (samplingDecision != null) { return new SentrySamplingResult(samplingDecision); } else { - return handleRootOtelSpan(traceId, parentContext); + return handleRootOtelSpan(traceId, parentContext, attributes); } } } private @NotNull SamplingResult handleRootOtelSpan( - final @NotNull String traceId, final @NotNull Context parentContext) { + final @NotNull String traceId, + final @NotNull Context parentContext, + final @NotNull Attributes attributes) { if (!scopes.getOptions().isTracingEnabled()) { return SamplingResult.create(SamplingDecision.RECORD_ONLY); } @@ -96,7 +100,11 @@ public SamplingResult shouldSample( .getOptions() .getInternalTracesSampler() .sample( - new SamplingContext(transactionContext, null, propagationContext.getSampleRand())); + new SamplingContext( + transactionContext, + null, + propagationContext.getSampleRand(), + toMapWithStringKeys(attributes))); if (!sentryDecision.getSampled()) { scopes @@ -135,6 +143,22 @@ public SamplingResult shouldSample( } } + private @NotNull Map toMapWithStringKeys(final @NotNull Attributes attributes) { + final @NotNull Map mapWithStringKeys = new HashMap<>(attributes.size()); + + if (attributes != null) { + attributes.forEach( + (key, value) -> { + if (key != null) { + final @NotNull String stringKey = key.getKey(); + mapWithStringKeys.put(stringKey, value); + } + }); + } + + return mapWithStringKeys; + } + @Override public String getDescription() { return "SentrySampler"; diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fef0f23bfd6..d5b24621973 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2023,7 +2023,8 @@ public final class io/sentry/RequestDetails { public final class io/sentry/SamplingContext { public fun (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;)V - public fun (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;Ljava/lang/Double;)V + public fun (Lio/sentry/TransactionContext;Lio/sentry/CustomSamplingContext;Ljava/lang/Double;Ljava/util/Map;)V + public fun getAttribute (Ljava/lang/String;)Ljava/lang/Object; public fun getCustomSamplingContext ()Lio/sentry/CustomSamplingContext; public fun getSampleRand ()Ljava/lang/Double; public fun getTransactionContext ()Lio/sentry/TransactionContext; diff --git a/sentry/src/main/java/io/sentry/SamplingContext.java b/sentry/src/main/java/io/sentry/SamplingContext.java index 711c03e21c5..17ce111d0c2 100644 --- a/sentry/src/main/java/io/sentry/SamplingContext.java +++ b/sentry/src/main/java/io/sentry/SamplingContext.java @@ -2,6 +2,8 @@ import io.sentry.util.Objects; import io.sentry.util.SentryRandom; +import java.util.Collections; +import java.util.Map; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -14,6 +16,7 @@ public final class SamplingContext { private final @NotNull TransactionContext transactionContext; private final @Nullable CustomSamplingContext customSamplingContext; private final @NotNull Double sampleRand; + private final @NotNull Map attributes; @Deprecated @SuppressWarnings("InlineMeSuggester") @@ -23,18 +26,20 @@ public final class SamplingContext { public SamplingContext( final @NotNull TransactionContext transactionContext, final @Nullable CustomSamplingContext customSamplingContext) { - this(transactionContext, customSamplingContext, SentryRandom.current().nextDouble()); + this(transactionContext, customSamplingContext, SentryRandom.current().nextDouble(), null); } @ApiStatus.Internal public SamplingContext( final @NotNull TransactionContext transactionContext, final @Nullable CustomSamplingContext customSamplingContext, - final @NotNull Double sampleRand) { + final @NotNull Double sampleRand, + final @Nullable Map attributes) { this.transactionContext = Objects.requireNonNull(transactionContext, "transactionContexts is required"); this.customSamplingContext = customSamplingContext; this.sampleRand = sampleRand; + this.attributes = attributes == null ? Collections.emptyMap() : attributes; } public @Nullable CustomSamplingContext getCustomSamplingContext() { @@ -48,4 +53,11 @@ public SamplingContext( public @NotNull Double getSampleRand() { return sampleRand; } + + public @Nullable Object getAttribute(final @Nullable String key) { + if (key == null) { + return null; + } + return this.attributes.get(key); + } } diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 92e146b9e46..f34b66680ee 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -860,7 +860,7 @@ public void flush(long timeoutMillis) { final Double sampleRand = getSampleRand(transactionContext); final SamplingContext samplingContext = new SamplingContext( - transactionContext, transactionOptions.getCustomSamplingContext(), sampleRand); + transactionContext, transactionOptions.getCustomSamplingContext(), sampleRand, null); final @NotNull TracesSampler tracesSampler = getOptions().getInternalTracesSampler(); @NotNull TracesSamplingDecision samplingDecision = tracesSampler.sample(samplingContext); transactionContext.setSamplingDecision(samplingDecision); diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 822609b2779..81097e4fe57 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -460,7 +460,8 @@ private static void handleAppStartProfilingConfig( TransactionContext appStartTransactionContext = new TransactionContext("app.launch", "profile"); appStartTransactionContext.setForNextAppStart(true); SamplingContext appStartSamplingContext = - new SamplingContext(appStartTransactionContext, null, SentryRandom.current().nextDouble()); + new SamplingContext( + appStartTransactionContext, null, SentryRandom.current().nextDouble(), null); return options.getInternalTracesSampler().sample(appStartSamplingContext); } diff --git a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt index 0fbc8e2f679..ff4bc6dc1da 100644 --- a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt +++ b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt @@ -46,7 +46,7 @@ class TracesSamplerTest { @Test fun `when tracesSampleRate is set and random returns greater number returns false`() { val sampler = fixture.getSut(tracesSampleRate = 0.2, profilesSampleRate = 0.2) - val samplingDecision = sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.9)) + val samplingDecision = sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.9, null)) assertFalse(samplingDecision.sampled) assertEquals(0.2, samplingDecision.sampleRate) assertEquals(0.9, samplingDecision.sampleRand) @@ -55,7 +55,7 @@ class TracesSamplerTest { @Test fun `when tracesSampleRate is set and random returns lower number returns true`() { val sampler = fixture.getSut(tracesSampleRate = 0.2, profilesSampleRate = 0.2) - val samplingDecision = sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.1)) + val samplingDecision = sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.1, null)) assertTrue(samplingDecision.sampled) assertEquals(0.2, samplingDecision.sampleRate) assertEquals(0.1, samplingDecision.sampleRand) @@ -64,7 +64,7 @@ class TracesSamplerTest { @Test fun `when profilesSampleRate is set and random returns greater number returns false`() { val sampler = fixture.getSut(tracesSampleRate = 1.0, profilesSampleRate = 0.2) - val samplingDecision = sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.9)) + val samplingDecision = sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.9, null)) assertTrue(samplingDecision.sampled) assertFalse(samplingDecision.profileSampled) assertEquals(0.2, samplingDecision.profileSampleRate) @@ -74,7 +74,7 @@ class TracesSamplerTest { @Test fun `when profilesSampleRate is set and random returns lower number returns true`() { val sampler = fixture.getSut(tracesSampleRate = 1.0, profilesSampleRate = 0.2) - val samplingDecision = sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.1)) + val samplingDecision = sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.1, null)) assertTrue(samplingDecision.sampled) assertTrue(samplingDecision.profileSampled) assertEquals(0.2, samplingDecision.profileSampleRate) @@ -84,7 +84,7 @@ class TracesSamplerTest { @Test fun `when trace is not sampled, profile is not sampled`() { val sampler = fixture.getSut(tracesSampleRate = 0.0, profilesSampleRate = 1.0) - val samplingDecision = sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.3)) + val samplingDecision = sampler.sample(SamplingContext(TransactionContext("name", "op"), null, 0.3, null)) assertFalse(samplingDecision.sampled) assertFalse(samplingDecision.profileSampled) assertEquals(1.0, samplingDecision.profileSampleRate) @@ -101,7 +101,8 @@ class TracesSamplerTest { SamplingContext( TransactionContext("name", "op"), CustomSamplingContext(), - 0.1 + 0.1, + null ) ) assertTrue(samplingDecision.sampled) @@ -116,7 +117,8 @@ class TracesSamplerTest { SamplingContext( TransactionContext("name", "op"), CustomSamplingContext(), - 0.1 + 0.1, + null ) ) assertTrue(samplingDecision.sampled) @@ -132,7 +134,8 @@ class TracesSamplerTest { SamplingContext( TransactionContext("name", "op"), CustomSamplingContext(), - 0.9 + 0.9, + null ) ) assertFalse(samplingDecision.sampled) @@ -147,7 +150,8 @@ class TracesSamplerTest { SamplingContext( TransactionContext("name", "op"), CustomSamplingContext(), - 0.9 + 0.9, + null ) ) assertTrue(samplingDecision.sampled) @@ -165,7 +169,8 @@ class TracesSamplerTest { SamplingContext( transactionContextParentSampled, CustomSamplingContext(), - 0.1 + 0.1, + null ) ) assertTrue(samplingDecision.sampled) @@ -182,7 +187,8 @@ class TracesSamplerTest { SamplingContext( transactionContextParentSampled, CustomSamplingContext(), - 0.1 + 0.1, + null ) ) assertTrue(samplingDecision.sampled) @@ -198,7 +204,8 @@ class TracesSamplerTest { SamplingContext( TransactionContext("name", "op"), CustomSamplingContext(), - 0.1 + 0.1, + null ) ) assertTrue(samplingDecision.sampled) @@ -213,7 +220,8 @@ class TracesSamplerTest { SamplingContext( TransactionContext("name", "op"), CustomSamplingContext(), - 0.1 + 0.1, + null ) ) assertTrue(samplingDecision.sampled) @@ -228,7 +236,8 @@ class TracesSamplerTest { SamplingContext( TransactionContext("name", "op"), CustomSamplingContext(), - 0.1 + 0.1, + null ) ) assertFalse(samplingDecision.sampled) @@ -243,7 +252,8 @@ class TracesSamplerTest { SamplingContext( TransactionContext("name", "op"), CustomSamplingContext(), - 0.1 + 0.1, + null ) ) assertTrue(samplingDecision.sampled) @@ -261,7 +271,8 @@ class TracesSamplerTest { SamplingContext( transactionContextParentNotSampled, CustomSamplingContext(), - 0.1 + 0.1, + null ) ) assertFalse(samplingDecision.sampled) @@ -276,7 +287,8 @@ class TracesSamplerTest { SamplingContext( transactionContextParentSampled, CustomSamplingContext(), - 0.1 + 0.1, + null ) ) assertTrue(samplingDecisionParentSampled.sampled) @@ -309,7 +321,7 @@ class TracesSamplerTest { val transactionContextNotSampled = TransactionContext("name", "op") transactionContextNotSampled.sampled = false val samplingDecision = - sampler.sample(SamplingContext(transactionContextNotSampled, CustomSamplingContext(), 0.1)) + sampler.sample(SamplingContext(transactionContextNotSampled, CustomSamplingContext(), 0.1, null)) assertFalse(samplingDecision.sampled) assertNull(samplingDecision.sampleRate) assertNotNull(samplingDecision.sampleRand) @@ -319,7 +331,7 @@ class TracesSamplerTest { val transactionContextSampled = TransactionContext("name", "op") transactionContextSampled.setSampled(true, true) val samplingDecisionContextSampled = - sampler.sample(SamplingContext(transactionContextSampled, CustomSamplingContext(), 0.1)) + sampler.sample(SamplingContext(transactionContextSampled, CustomSamplingContext(), 0.1, null)) assertTrue(samplingDecisionContextSampled.sampled) assertNull(samplingDecisionContextSampled.sampleRate) assertNotNull(samplingDecisionContextSampled.sampleRand) @@ -329,7 +341,7 @@ class TracesSamplerTest { val transactionContextUnsampledWithProfile = TransactionContext("name", "op") transactionContextUnsampledWithProfile.setSampled(false, true) val samplingDecisionContextUnsampledWithProfile = - sampler.sample(SamplingContext(transactionContextUnsampledWithProfile, CustomSamplingContext(), 0.1)) + sampler.sample(SamplingContext(transactionContextUnsampledWithProfile, CustomSamplingContext(), 0.1, null)) assertFalse(samplingDecisionContextUnsampledWithProfile.sampled) assertNull(samplingDecisionContextUnsampledWithProfile.sampleRate) assertNotNull(samplingDecisionContextUnsampledWithProfile.sampleRand) @@ -350,7 +362,7 @@ class TracesSamplerTest { logger = logger ) val decision = sampler.sample( - SamplingContext(TransactionContext("name", "op"), null, 0.1) + SamplingContext(TransactionContext("name", "op"), null, 0.1, null) ) assertFalse(decision.profileSampled) verify(logger).log(eq(SentryLevel.ERROR), any(), eq(exception)) @@ -367,7 +379,7 @@ class TracesSamplerTest { } ) val decision = sampler.sample( - SamplingContext(TransactionContext("name", "op"), null, 0.0) + SamplingContext(TransactionContext("name", "op"), null, 0.0, null) ) assertTrue(decision.profileSampled) assertEquals(0.0, decision.sampleRand) @@ -385,7 +397,7 @@ class TracesSamplerTest { logger = logger ) val decision = sampler.sample( - SamplingContext(TransactionContext("name", "op"), null, 0.1) + SamplingContext(TransactionContext("name", "op"), null, 0.1, null) ) assertFalse(decision.sampled) assertEquals(0.1, decision.sampleRand) @@ -402,9 +414,57 @@ class TracesSamplerTest { } ) val decision = sampler.sample( - SamplingContext(TransactionContext("name", "op"), null, 0.0) + SamplingContext(TransactionContext("name", "op"), null, 0.0, null) ) assertTrue(decision.sampled) assertEquals(0.0, decision.sampleRand) } + + @Test + fun `attributes can be accessed in callback`() { + var attributeValue: Any? = null + val sampler = fixture.getSut( + tracesSamplerCallback = { samplingContext -> + attributeValue = samplingContext.getAttribute("attr") + 1.0 + } + ) + val decision = sampler.sample( + SamplingContext(TransactionContext("name", "op"), null, 0.0, mapOf("attr" to "123")) + ) + assertTrue(decision.sampled) + assertEquals("123", attributeValue) + } + + @Test + fun `non existing attribute returns null in callback`() { + var attributeValue: Any? = null + val sampler = fixture.getSut( + tracesSamplerCallback = { samplingContext -> + attributeValue = samplingContext.getAttribute("i-do-not-exist") + 1.0 + } + ) + val decision = sampler.sample( + SamplingContext(TransactionContext("name", "op"), null, 0.0, mapOf("attr" to "123")) + ) + assertTrue(decision.sampled) + assertNull(attributeValue) + } + + @Test + fun `null attributes return null`() { + var attributeValue: Any? = null + val sampler = fixture.getSut( + tracesSamplerCallback = { samplingContext -> + attributeValue = samplingContext.getAttribute("i-do-not-exist") + 1.0 + } + ) + val decision = sampler.sample( + SamplingContext(TransactionContext("name", "op"), null, 0.0, null) + ) + assertTrue(decision.sampled) + assertNull(attributeValue) + } } From 28828754cf9dde8c9054ec8774b2a08b578894a5 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 13 Mar 2025 09:41:44 +0100 Subject: [PATCH 031/914] Fix AbstractMethodError when using SentryTraced for Jetpack Compose (#4255) * Override default interface impl to fix AbstractMethodError * Update Changelog --- CHANGELOG.md | 1 + .../androidMain/kotlin/io/sentry/compose/SentryModifier.kt | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02fadfe5581..636abee6d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - If you are using OpenTelemetry v1 `OpenTelemetryAppender`, please consider upgrading to v2 - Pass OpenTelemetry span attributes into TracesSampler callback ([#4253](https://github.com/getsentry/sentry-java/pull/4253)) - `SamplingContext` now has a `getAttribute` method that grants access to OpenTelemetry span attributes via their String key (e.g. `http.request.method`) +- Fix AbstractMethodError when using SentryTraced for Jetpack Compose ([#4255](https://github.com/getsentry/sentry-java/pull/4255)) ### Features diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index 39ac3216610..e2b7bb07192 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -51,6 +51,12 @@ public object SentryModifier { Modifier.Node(), SemanticsModifierNode { + override val shouldClearDescendantSemantics: Boolean + get() = false + + override val shouldMergeDescendantSemantics: Boolean + get() = false + override fun SemanticsPropertyReceiver.applySemantics() { this[SentryTag] = tag } From 0b8cee05257d900681b021a326c5c9d5deb6287d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 13 Mar 2025 09:52:53 +0100 Subject: [PATCH 032/914] Update CHANGELOG.md (#4248) --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 636abee6d83..e3e26188c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -482,6 +482,24 @@ If you have been using `8.0.0-rc.4` of the Java SDK, here's the new changes that - We are planning to improve this in the future but opted for this fix first. - Fix swallow NDK loadLibrary errors ([#4082](https://github.com/getsentry/sentry-java/pull/4082)) +## 7.22.1 + +### Fixes + +- Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4216](https://github.com/getsentry/sentry-java/pull/4216)) +- Fix properly reset application/content-provider timespans for warm app starts ([#4244](https://github.com/getsentry/sentry-java/pull/4244)) + +## 7.22.0 + +### Fixes + +- Session Replay: Fix various crashes and issues ([#4135](https://github.com/getsentry/sentry-java/pull/4135)) + - Fix `FileNotFoundException` when trying to read/write `.ongoing_segment` file + - Fix `IllegalStateException` when registering `onDrawListener` + - Fix SIGABRT native crashes on Motorola devices when encoding a video +- (Jetpack Compose) Modifier.sentryTag now uses Modifier.Node ([#4029](https://github.com/getsentry/sentry-java/pull/4029)) + - This allows Composables that use this modifier to be skippable + ## 7.21.0 ### Fixes From 9fba6e31191eaaf9d0618ef9ab4b83e26730cec6 Mon Sep 17 00:00:00 2001 From: Lorenzo Cian Date: Fri, 14 Mar 2025 10:18:34 +0100 Subject: [PATCH 033/914] Fix misuses of `CopyOnWriteArrayList` (#4247) * Avoid copying and iterate correctly on `SentryTracer.children` * another place * changelog * fix * remove unnecessary test * wip * improve * improve * Update CHANGELOG.md Co-authored-by: Alexander Dinauer --------- Co-authored-by: Alexander Dinauer --- CHANGELOG.md | 2 ++ sentry/api/sentry.api | 1 + .../src/main/java/io/sentry/SentryTracer.java | 34 ++++++++++--------- .../java/io/sentry/util/CollectionUtils.java | 21 ++++++++++++ .../io/sentry/util/CollectionUtilsTest.kt | 23 +++++++++++++ 5 files changed, 65 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e26188c64..52ea22d9bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ - Pass OpenTelemetry span attributes into TracesSampler callback ([#4253](https://github.com/getsentry/sentry-java/pull/4253)) - `SamplingContext` now has a `getAttribute` method that grants access to OpenTelemetry span attributes via their String key (e.g. `http.request.method`) - Fix AbstractMethodError when using SentryTraced for Jetpack Compose ([#4255](https://github.com/getsentry/sentry-java/pull/4255)) +- Avoid unnecessary copies when using `CopyOnWriteArrayList` ([#4247](https://github.com/getsentry/sentry-java/pull/4247)) + - This affects in particular `SentryTracer.getLatestActiveSpan` which would have previously copied all child span references. This may have caused `OutOfMemoryError` on certain devices due to high frequency of calling the method. ### Features diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d5b24621973..2c6aaaea2cf 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -6095,6 +6095,7 @@ public final class io/sentry/util/CollectionUtils { public static fun newArrayList (Ljava/util/List;)Ljava/util/List; public static fun newConcurrentHashMap (Ljava/util/Map;)Ljava/util/Map; public static fun newHashMap (Ljava/util/Map;)Ljava/util/Map; + public static fun reverseListIterator (Ljava/util/concurrent/CopyOnWriteArrayList;)Ljava/util/ListIterator; public static fun size (Ljava/lang/Iterable;)I } diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index cc832a136ef..af5459a1a37 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -5,9 +5,9 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; import io.sentry.util.SpanUtils; -import java.util.ArrayList; import java.util.List; import java.util.ListIterator; import java.util.Map; @@ -155,7 +155,9 @@ private void onDeadlineTimeoutReached() { // abort all child-spans first, this ensures the transaction can be finished, // even if waitForChildren is true // iterate in reverse order to ensure leaf spans are processed before their parents - @NotNull final ListIterator iterator = children.listIterator(children.size()); + @NotNull + final ListIterator iterator = + CollectionUtils.reverseListIterator((CopyOnWriteArrayList) this.children); while (iterator.hasPrevious()) { @NotNull final Span span = iterator.previous(); span.setSpanFinishedCallback(null); @@ -677,14 +679,13 @@ private void updateBaggageValues(final @NotNull Baggage baggage) { } private boolean hasAllChildrenFinished() { - final List spans = new ArrayList<>(this.children); - if (!spans.isEmpty()) { - for (final Span span : spans) { - // This is used in the spanFinishCallback, when the span isn't finished, but has a finish - // date - if (!span.isFinished() && span.getFinishDate() == null) { - return false; - } + @NotNull final ListIterator iterator = this.children.listIterator(); + while (iterator.hasNext()) { + @NotNull final Span span = iterator.next(); + // This is used in the spanFinishCallback, when the span isn't finished, but has a finish + // date + if (!span.isFinished() && span.getFinishDate() == null) { + return false; } } return true; @@ -909,12 +910,13 @@ public void setName(@NotNull String name, @NotNull TransactionNameSource transac @Override public @Nullable ISpan getLatestActiveSpan() { - final List spans = new ArrayList<>(this.children); - if (!spans.isEmpty()) { - for (int i = spans.size() - 1; i >= 0; i--) { - if (!spans.get(i).isFinished()) { - return spans.get(i); - } + @NotNull + final ListIterator iterator = + CollectionUtils.reverseListIterator((CopyOnWriteArrayList) this.children); + while (iterator.hasPrevious()) { + @NotNull final Span span = iterator.previous(); + if (!span.isFinished()) { + return span; } } return null; diff --git a/sentry/src/main/java/io/sentry/util/CollectionUtils.java b/sentry/src/main/java/io/sentry/util/CollectionUtils.java index f3a0e1d9d74..266055fa1ce 100644 --- a/sentry/src/main/java/io/sentry/util/CollectionUtils.java +++ b/sentry/src/main/java/io/sentry/util/CollectionUtils.java @@ -4,8 +4,10 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; +import java.util.ListIterator; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -179,4 +181,23 @@ public interface Predicate { public interface Mapper { R map(T t); } + + /** + * Returns a reverse iterator, where the first (resp. last) valid call to `prev` returns the last + * (resp. first) element that would be returned when iterating forwards. Note that this differs + * from the behavior of e.g. `org.apache.commons.collections4.iterators.ReverseListIterator`, + * where you need to iterate using `next` instead. We use the concrete type `CopyOnWriteArrayList` + * here as we are relying on the fact that its copy constructor only copies the reference to an + * internal array. We don't want to use this for other `List` implementations, as it could lead to + * an unnecessary copy of the elements instead. + * + * @param list the `CopyOnWriteArrayList` to get the reverse iterator for + * @param the type + * @return a reverse iterator over `list` + */ + public static @NotNull ListIterator reverseListIterator( + final @NotNull CopyOnWriteArrayList list) { + final @NotNull CopyOnWriteArrayList copy = new CopyOnWriteArrayList<>(list); + return copy.listIterator(copy.size()); + } } diff --git a/sentry/src/test/java/io/sentry/util/CollectionUtilsTest.kt b/sentry/src/test/java/io/sentry/util/CollectionUtilsTest.kt index ebdaff477b1..3772bb2aeb9 100644 --- a/sentry/src/test/java/io/sentry/util/CollectionUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/CollectionUtilsTest.kt @@ -2,6 +2,7 @@ package io.sentry.util import io.sentry.JsonObjectReader import java.io.StringReader +import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -79,4 +80,26 @@ class CollectionUtilsTest { fun `contains returns false if element is not present`() { assertFalse(CollectionUtils.contains(arrayOf("one", "two", "three"), "four")) } + + @Test + fun `reverseListIterator returns empty iterator if list is empty`() { + val list = CopyOnWriteArrayList() + val iterator = CollectionUtils.reverseListIterator(list) + assertFalse(iterator.hasNext()) + assertFalse(iterator.hasPrevious()) + } + + @Test + fun `reverseListIterator returns reversed iterator if list is not empty`() { + val elements = listOf("one", "two", "three") + val list = CopyOnWriteArrayList(elements) + val iterator = CollectionUtils.reverseListIterator(list) + assertFalse(iterator.hasNext()) + assertTrue(iterator.hasPrevious()) + val reversedElements = mutableListOf() + while (iterator.hasPrevious()) { + reversedElements.add(iterator.previous()) + } + assertEquals(elements.reversed(), reversedElements) + } } From e5e69058abec71d76cc3fabee390b8980c916f5c Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 14 Mar 2025 11:31:56 +0100 Subject: [PATCH 034/914] Assume `http.client` for span `op` if not a root span (#4257) * Assume http.client for span op if not a root span * changelog --- CHANGELOG.md | 1 + .../SpanDescriptionExtractor.java | 37 +- .../kotlin/SpanDescriptionExtractorTest.kt | 344 ++++++++++++++++++ 3 files changed, 363 insertions(+), 19 deletions(-) create mode 100644 sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 52ea22d9bda..f93721f860d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Pass OpenTelemetry span attributes into TracesSampler callback ([#4253](https://github.com/getsentry/sentry-java/pull/4253)) - `SamplingContext` now has a `getAttribute` method that grants access to OpenTelemetry span attributes via their String key (e.g. `http.request.method`) - Fix AbstractMethodError when using SentryTraced for Jetpack Compose ([#4255](https://github.com/getsentry/sentry-java/pull/4255)) +- Assume `http.client` for span `op` if not a root span ([#4257](https://github.com/getsentry/sentry-java/pull/4257)) - Avoid unnecessary copies when using `CopyOnWriteArrayList` ([#4247](https://github.com/getsentry/sentry-java/pull/4247)) - This affects in particular `SentryTracer.getLatestActiveSpan` which would have previously copied all child span references. This may have caused `OutOfMemoryError` on certain devices due to high frequency of calling the method. diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index 2047bd37f80..b66555d68c9 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -18,23 +18,16 @@ public final class SpanDescriptionExtractor { @SuppressWarnings("deprecation") public @NotNull OtelSpanInfo extractSpanInfo( final @NotNull SpanData otelSpan, final @Nullable IOtelSpanWrapper sentrySpan) { - if (!isInternalSpanKind(otelSpan)) { - final @NotNull Attributes attributes = otelSpan.getAttributes(); - - final @Nullable String httpMethod = attributes.get(HttpAttributes.HTTP_REQUEST_METHOD); - if (httpMethod != null) { - return descriptionForHttpMethod(otelSpan, httpMethod); - } + final @NotNull Attributes attributes = otelSpan.getAttributes(); - final @Nullable String httpRequestMethod = attributes.get(HttpAttributes.HTTP_REQUEST_METHOD); - if (httpRequestMethod != null) { - return descriptionForHttpMethod(otelSpan, httpRequestMethod); - } + final @Nullable String httpMethod = attributes.get(HttpAttributes.HTTP_REQUEST_METHOD); + if (httpMethod != null) { + return descriptionForHttpMethod(otelSpan, httpMethod); + } - final @Nullable String dbSystem = attributes.get(DbIncubatingAttributes.DB_SYSTEM); - if (dbSystem != null) { - return descriptionForDbSystem(otelSpan); - } + final @Nullable String dbSystem = attributes.get(DbIncubatingAttributes.DB_SYSTEM); + if (dbSystem != null) { + return descriptionForDbSystem(otelSpan); } final @NotNull String name = otelSpan.getName(); @@ -44,10 +37,6 @@ public final class SpanDescriptionExtractor { return new OtelSpanInfo(name, description, TransactionNameSource.CUSTOM); } - private boolean isInternalSpanKind(final @NotNull SpanData otelSpan) { - return SpanKind.INTERNAL.equals(otelSpan.getKind()); - } - @SuppressWarnings("deprecation") private OtelSpanInfo descriptionForHttpMethod( final @NotNull SpanData otelSpan, final @NotNull String httpMethod) { @@ -60,6 +49,12 @@ private OtelSpanInfo descriptionForHttpMethod( opBuilder.append(".client"); } else if (SpanKind.SERVER.equals(kind)) { opBuilder.append(".server"); + } else { + // we cannot be certain that a root span is a server span as it might simply be a client span + // without parent + if (!isRootSpan(otelSpan)) { + opBuilder.append(".client"); + } } final @Nullable String httpTarget = attributes.get(HttpIncubatingAttributes.HTTP_TARGET); final @Nullable String httpRoute = attributes.get(HttpAttributes.HTTP_ROUTE); @@ -92,6 +87,10 @@ private OtelSpanInfo descriptionForHttpMethod( return new OtelSpanInfo(op, description, transactionNameSource); } + private static boolean isRootSpan(SpanData otelSpan) { + return !otelSpan.getParentSpanContext().isValid() || otelSpan.getParentSpanContext().isRemote(); + } + @SuppressWarnings("deprecation") private OtelSpanInfo descriptionForDbSystem(final @NotNull SpanData otelSpan) { final @NotNull Attributes attributes = otelSpan.getAttributes(); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt new file mode 100644 index 00000000000..6d33a7df7c1 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -0,0 +1,344 @@ +package io.sentry.opentelemetry + +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.sdk.internal.AttributesMap +import io.opentelemetry.sdk.trace.data.SpanData +import io.opentelemetry.semconv.HttpAttributes +import io.opentelemetry.semconv.UrlAttributes +import io.opentelemetry.semconv.incubating.DbIncubatingAttributes +import io.opentelemetry.semconv.incubating.HttpIncubatingAttributes +import io.sentry.protocol.TransactionNameSource +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SpanDescriptionExtractorTest { + + private class Fixture { + val sentrySpan = mock() + val otelSpan = mock() + val attributes = AttributesMap.create(100, 100) + var parentSpanContext = SpanContext.getInvalid() + var spanKind = SpanKind.INTERNAL + var spanName: String? = null + var spanDescription: String? = null + + fun setup() { + whenever(otelSpan.attributes).thenReturn(attributes) + whenever(otelSpan.parentSpanContext).thenReturn(parentSpanContext) + whenever(otelSpan.kind).thenReturn(spanKind) + spanName?.let { + whenever(otelSpan.name).thenReturn(it) + } + spanDescription?.let { + whenever(sentrySpan.description).thenReturn(it) + } + } + } + + private val fixture = Fixture() + + @Test + fun `sets op to http server for kind SERVER`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `sets op to http client for kind CLIENT`() { + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.client", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `sets op to http without server for root span with http GET`() { + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `sets op to http without server for non root span with remote parent with http GET`() { + givenParentContext(createSpanContext(true)) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `sets op to http client for non root span with http GET`() { + givenParentContext(createSpanContext(false)) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.client", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `uses URL_FULL for description`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_FULL to "https://sentry.io/some/path?q=1#top" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertEquals("GET https://sentry.io/some/path?q=1#top", info.description) + assertEquals(TransactionNameSource.URL, info.transactionNameSource) + } + + @Test + fun `uses URL_PATH for description`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_PATH to "/some/path" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertEquals("GET /some/path", info.description) + assertEquals(TransactionNameSource.URL, info.transactionNameSource) + } + + @Test + fun `uses HTTP_TARGET for description`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + HttpAttributes.HTTP_ROUTE to "/some/{id}", + HttpIncubatingAttributes.HTTP_TARGET to "some/path?q=1#top", + UrlAttributes.URL_PATH to "/some/path" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertEquals("GET /some/{id}", info.description) + assertEquals(TransactionNameSource.ROUTE, info.transactionNameSource) + } + + @Test + fun `uses span name as description fallback`() { + givenSpanKind(SpanKind.SERVER) + givenSpanName("span name") + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertEquals("span name", info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `no description if no span name as fallback`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `sets op to db for span with db system and query text`() { + givenAttributes( + mapOf( + DbIncubatingAttributes.DB_SYSTEM to "some", + DbIncubatingAttributes.DB_QUERY_TEXT to "SELECT * FROM tbl" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("db", info.op) + assertEquals("SELECT * FROM tbl", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `sets op to db for span with db system and statement`() { + givenAttributes( + mapOf( + DbIncubatingAttributes.DB_SYSTEM to "some", + DbIncubatingAttributes.DB_STATEMENT to "SELECT * FROM tbl" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("db", info.op) + assertEquals("SELECT * FROM tbl", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `sets op to db for span with db system`() { + givenAttributes( + mapOf( + DbIncubatingAttributes.DB_SYSTEM to "some" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("db", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `sets op to db for span with db system fallback to span name as description`() { + givenSpanName("span name") + givenAttributes( + mapOf( + DbIncubatingAttributes.DB_SYSTEM to "some" + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("db", info.op) + assertEquals("span name", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `uses span name as op and description if no relevant attributes`() { + givenSpanName("span name") + givenAttributes(emptyMap()) + + val info = whenExtractingSpanInfo() + + assertEquals("span name", info.op) + assertEquals("span name", info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `uses existing sentry span description as description`() { + givenSpanName("span name") + givenSentrySpanDescription("span description") + givenAttributes(emptyMap()) + + val info = whenExtractingSpanInfo() + + assertEquals("span name", info.op) + assertEquals("span description", info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + private fun createSpanContext(isRemote: Boolean, traceId: String = "f9118105af4a2d42b4124532cd1065ff", spanId: String = "424cffc8f94feeee"): SpanContext { + if (isRemote) { + return SpanContext.createFromRemoteParent( + traceId, + spanId, + TraceFlags.getSampled(), + TraceState.getDefault() + ) + } else { + return SpanContext.create( + traceId, + spanId, + TraceFlags.getSampled(), + TraceState.getDefault() + ) + } + } + + private fun givenAttributes(map: Map, Any>) { + map.forEach { k, v -> + fixture.attributes.put(k, v) + } + } + + private fun whenExtractingSpanInfo(): OtelSpanInfo { + fixture.setup() + return SpanDescriptionExtractor().extractSpanInfo(fixture.otelSpan, fixture.sentrySpan) + } + + private fun givenParentContext(parentContext: SpanContext) { + fixture.parentSpanContext = parentContext + } + + private fun givenSpanName(name: String) { + fixture.spanName = name + } + + private fun givenSentrySpanDescription(description: String) { + fixture.spanDescription = description + } + + private fun givenSpanKind(spanKind: SpanKind) { + fixture.spanKind = spanKind + } +} From be2d574c27cfc7e41c1cedc0d129bb707dfad4b1 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Fri, 14 Mar 2025 10:32:34 +0000 Subject: [PATCH 035/914] release: 8.4.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f93721f860d..9875e8d5483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.4.0 ### Fixes diff --git a/gradle.properties b/gradle.properties index 9a32045706a..65966b6c7f6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ org.gradle.workers.max=2 android.useAndroidX=true # Release information -versionName=8.3.0 +versionName=8.4.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From b61429a773645a1c72e14113234157a4f8f471b8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 14 Mar 2025 12:31:23 +0100 Subject: [PATCH 036/914] Reduce excessive CPU usage when serializing breadcrumbs to disk (#4181) * WIP * WIP * Remove redundant line * Add Tests * api dump * Formatting * REset scope cache on new init * Clean up * Comment * Changelog * Workaround https://github.com/square/tape/issues/173 * Add a comment to setBreadcrumbs * Address PR review * Update CHANGELOG.md --- CHANGELOG.md | 6 + buildSrc/src/main/java/Config.kt | 1 + .../core/AndroidOptionsInitializer.java | 14 +- .../android/core/AnrV2EventProcessor.java | 47 +- .../core/AndroidOptionsInitializerTest.kt | 7 +- .../android/core/AnrV2EventProcessorTest.kt | 16 +- .../sentry/android/core/SentryAndroidTest.kt | 56 +- .../android/replay/ReplayIntegration.kt | 8 +- .../android/replay/ReplayIntegrationTest.kt | 22 +- sentry/api/sentry.api | 50 +- sentry/build.gradle.kts | 1 + sentry/src/main/java/io/sentry/Sentry.java | 11 + .../main/java/io/sentry/SentryOptions.java | 12 + .../main/java/io/sentry/cache/CacheUtils.java | 17 +- .../sentry/cache/PersistingScopeObserver.java | 148 +++- .../sentry/cache/tape/EmptyObjectQueue.java | 52 ++ .../io/sentry/cache/tape/FileObjectQueue.java | 148 ++++ .../io/sentry/cache/tape/ObjectQueue.java | 108 +++ .../java/io/sentry/cache/tape/QueueFile.java | 817 ++++++++++++++++++ .../java/io/sentry/cache/CacheUtilsTest.kt | 10 + .../cache/PersistingScopeObserverTest.kt | 71 +- .../sentry/cache/tape/CorruptQueueFileTest.kt | 43 + .../io/sentry/cache/tape/ObjectQueueTest.kt | 252 ++++++ .../io/sentry/cache/tape/QueueFileTest.kt | 730 ++++++++++++++++ .../src/test/resources/corrupt_queue_file.txt | Bin 0 -> 4100 bytes 25 files changed, 2523 insertions(+), 124 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java create mode 100644 sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java create mode 100644 sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java create mode 100644 sentry/src/main/java/io/sentry/cache/tape/QueueFile.java create mode 100644 sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt create mode 100644 sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt create mode 100644 sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt create mode 100644 sentry/src/test/resources/corrupt_queue_file.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9875e8d5483..bbf069a858c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) + ## 8.4.0 ### Fixes diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index e24edb6e3d0..126c14b64b6 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -213,6 +213,7 @@ object Config { val msgpack = "org.msgpack:msgpack-core:0.9.8" val leakCanaryInstrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.14" val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4:1.6.8" + val okio = "com.squareup.okio:okio:1.13.0" } object QualityPlugins { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index c694b3d4b0d..90b6c5d741e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -148,6 +148,11 @@ static void initializeIntegrationsAndProcessors( new AndroidConnectionStatusProvider(context, options.getLogger(), buildInfoProvider)); } + if (options.getCacheDirPath() != null) { + options.addScopeObserver(new PersistingScopeObserver(options)); + options.addOptionsObserver(new PersistingOptionsObserver(options)); + } + options.addEventProcessor(new DeduplicateMultithreadedEventProcessor(options)); options.addEventProcessor( new DefaultAndroidEventProcessor(context, buildInfoProvider, options)); @@ -225,13 +230,6 @@ static void initializeIntegrationsAndProcessors( } } options.setTransactionPerformanceCollector(new DefaultTransactionPerformanceCollector(options)); - - if (options.getCacheDirPath() != null) { - if (options.isEnableScopePersistence()) { - options.addScopeObserver(new PersistingScopeObserver(options)); - } - options.addOptionsObserver(new PersistingOptionsObserver(options)); - } } static void installDefaultIntegrations( @@ -277,6 +275,8 @@ static void installDefaultIntegrations( // AppLifecycleIntegration has to be installed before AnrIntegration, because AnrIntegration // relies on AppState set by it options.addIntegration(new AppLifecycleIntegration()); + // AnrIntegration must be installed before ReplayIntegration, as ReplayIntegration relies on + // it to set the replayId in case of an ANR options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); // registerActivityLifecycleCallbacks is only available if Context is an AppContext diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 216a4424c2a..d3c6bd31119 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -33,6 +33,7 @@ import io.sentry.SentryEvent; import io.sentry.SentryExceptionFactory; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; import io.sentry.SpanContext; import io.sentry.android.core.internal.util.CpuInfoUtils; @@ -83,6 +84,8 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @NotNull SentryExceptionFactory sentryExceptionFactory; + private final @Nullable PersistingScopeObserver persistingScopeObserver; + public AnrV2EventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, @@ -90,6 +93,7 @@ public AnrV2EventProcessor( this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; + this.persistingScopeObserver = options.findPersistingScopeObserver(); final SentryStackTraceFactory sentryStackTraceFactory = new SentryStackTraceFactory(this.options); @@ -188,8 +192,7 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { } private void setReplayId(final @NotNull SentryEvent event) { - @Nullable - String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + @Nullable String persistedReplayId = readFromDisk(options, REPLAY_FILENAME, String.class); final @NotNull File replayFolder = new File(options.getCacheDirPath(), "replay_" + persistedReplayId); if (!replayFolder.exists()) { @@ -224,8 +227,7 @@ private void setReplayId(final @NotNull SentryEvent event) { } private void setTrace(final @NotNull SentryEvent event) { - final SpanContext spanContext = - PersistingScopeObserver.read(options, TRACE_FILENAME, SpanContext.class); + final SpanContext spanContext = readFromDisk(options, TRACE_FILENAME, SpanContext.class); if (event.getContexts().getTrace() == null) { if (spanContext != null && spanContext.getSpanId() != null @@ -236,8 +238,7 @@ private void setTrace(final @NotNull SentryEvent event) { } private void setLevel(final @NotNull SentryEvent event) { - final SentryLevel level = - PersistingScopeObserver.read(options, LEVEL_FILENAME, SentryLevel.class); + final SentryLevel level = readFromDisk(options, LEVEL_FILENAME, SentryLevel.class); if (event.getLevel() == null) { event.setLevel(level); } @@ -246,7 +247,7 @@ private void setLevel(final @NotNull SentryEvent event) { @SuppressWarnings("unchecked") private void setFingerprints(final @NotNull SentryEvent event, final @NotNull Object hint) { final List fingerprint = - (List) PersistingScopeObserver.read(options, FINGERPRINT_FILENAME, List.class); + (List) readFromDisk(options, FINGERPRINT_FILENAME, List.class); if (event.getFingerprints() == null) { event.setFingerprints(fingerprint); } @@ -262,16 +263,14 @@ private void setFingerprints(final @NotNull SentryEvent event, final @NotNull Ob } private void setTransaction(final @NotNull SentryEvent event) { - final String transaction = - PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String.class); + final String transaction = readFromDisk(options, TRANSACTION_FILENAME, String.class); if (event.getTransaction() == null) { event.setTransaction(transaction); } } private void setContexts(final @NotNull SentryBaseEvent event) { - final Contexts persistedContexts = - PersistingScopeObserver.read(options, CONTEXTS_FILENAME, Contexts.class); + final Contexts persistedContexts = readFromDisk(options, CONTEXTS_FILENAME, Contexts.class); if (persistedContexts == null) { return; } @@ -291,7 +290,7 @@ private void setContexts(final @NotNull SentryBaseEvent event) { @SuppressWarnings("unchecked") private void setExtras(final @NotNull SentryBaseEvent event) { final Map extras = - (Map) PersistingScopeObserver.read(options, EXTRAS_FILENAME, Map.class); + (Map) readFromDisk(options, EXTRAS_FILENAME, Map.class); if (extras == null) { return; } @@ -309,14 +308,12 @@ private void setExtras(final @NotNull SentryBaseEvent event) { @SuppressWarnings("unchecked") private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { final List breadcrumbs = - (List) - PersistingScopeObserver.read( - options, BREADCRUMBS_FILENAME, List.class, new Breadcrumb.Deserializer()); + (List) readFromDisk(options, BREADCRUMBS_FILENAME, List.class); if (breadcrumbs == null) { return; } if (event.getBreadcrumbs() == null) { - event.setBreadcrumbs(new ArrayList<>(breadcrumbs)); + event.setBreadcrumbs(breadcrumbs); } else { event.getBreadcrumbs().addAll(breadcrumbs); } @@ -326,7 +323,7 @@ private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { private void setScopeTags(final @NotNull SentryBaseEvent event) { final Map tags = (Map) - PersistingScopeObserver.read(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); + readFromDisk(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); if (tags == null) { return; } @@ -343,19 +340,29 @@ private void setScopeTags(final @NotNull SentryBaseEvent event) { private void setUser(final @NotNull SentryBaseEvent event) { if (event.getUser() == null) { - final User user = PersistingScopeObserver.read(options, USER_FILENAME, User.class); + final User user = readFromDisk(options, USER_FILENAME, User.class); event.setUser(user); } } private void setRequest(final @NotNull SentryBaseEvent event) { if (event.getRequest() == null) { - final Request request = - PersistingScopeObserver.read(options, REQUEST_FILENAME, Request.class); + final Request request = readFromDisk(options, REQUEST_FILENAME, Request.class); event.setRequest(request); } } + private @Nullable T readFromDisk( + final @NotNull SentryOptions options, + final @NotNull String fileName, + final @NotNull Class clazz) { + if (persistingScopeObserver == null) { + return null; + } + + return persistingScopeObserver.read(options, fileName, clazz); + } + // endregion // region options persisted values diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index bb5efe3aab1..401e65fa1ab 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -20,6 +20,7 @@ import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingScopeObserver import io.sentry.compose.gestures.ComposeGestureTargetLocator +import io.sentry.test.ImmediateExecutorService import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -55,6 +56,7 @@ class AndroidOptionsInitializerTest { configureContext: Context.() -> Unit = {}, assets: AssetManager? = null ) { + sentryOptions.executorService = ImmediateExecutorService() mockContext = if (metadata != null) { ContextUtilsTestHelper.mockMetaData( mockContext = ContextUtilsTestHelper.createMockContext(hasAppContext), @@ -724,9 +726,10 @@ class AndroidOptionsInitializerTest { } @Test - fun `PersistingScopeObserver is not set to options, if scope persistence is disabled`() { + fun `PersistingScopeObserver is no-op, if scope persistence is disabled`() { fixture.initSut(configureOptions = { isEnableScopePersistence = false }) - assertTrue { fixture.sentryOptions.scopeObservers.none { it is PersistingScopeObserver } } + fixture.sentryOptions.findPersistingScopeObserver()?.setTags(mapOf("key" to "value")) + assertFalse(File(AndroidOptionsInitializer.getCacheDir(fixture.context), PersistingScopeObserver.SCOPE_CACHE).exists()) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index fe69a3157d8..4eb4c773d7c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -35,6 +35,7 @@ import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME import io.sentry.cache.PersistingScopeObserver.USER_FILENAME +import io.sentry.cache.tape.QueueFile import io.sentry.hints.AbnormalExit import io.sentry.hints.Backfillable import io.sentry.protocol.Browser @@ -61,6 +62,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowBuild +import java.io.ByteArrayOutputStream import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -98,6 +100,7 @@ class AnrV2EventProcessorTest { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" options.isSendDefaultPii = isSendDefaultPii + options.addScopeObserver(PersistingScopeObserver(options)) whenever(buildInfo.sdkInfoVersion).thenReturn(currentSdk) whenever(buildInfo.isEmulator).thenReturn(true) @@ -147,7 +150,16 @@ class AnrV2EventProcessorTest { fun persistScope(filename: String, entity: T) { val dir = File(options.cacheDirPath, SCOPE_CACHE).also { it.mkdirs() } val file = File(dir, filename) - options.serializer.serialize(entity, file.writer()) + if (filename == BREADCRUMBS_FILENAME) { + val queueFile = QueueFile.Builder(file).build() + (entity as List).forEach { crumb -> + val baos = ByteArrayOutputStream() + options.serializer.serialize(crumb, baos.writer()) + queueFile.add(baos.toByteArray()) + } + } else { + options.serializer.serialize(entity, file.writer()) + } } fun persistOptions(filename: String, entity: T) { @@ -621,7 +633,7 @@ class AnrV2EventProcessorTest { val processed = processor.process(SentryEvent(), hint)!! assertEquals(replayId1.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) - assertEquals(replayId1.toString(), PersistingScopeObserver.read(fixture.options, REPLAY_FILENAME, String::class.java)) + assertEquals(replayId1.toString(), fixture.options.findPersistingScopeObserver()?.read(fixture.options, REPLAY_FILENAME, String::class.java)) } private fun processEvent( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index a3a0f7d4f95..1f52891724e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -11,6 +11,7 @@ import android.os.SystemClock import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb +import io.sentry.DateUtils import io.sentry.Hint import io.sentry.ILogger import io.sentry.ISentryClient @@ -37,10 +38,13 @@ import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME -import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME +import io.sentry.cache.tape.QueueFile +import io.sentry.protocol.Contexts +import io.sentry.protocol.SentryId import io.sentry.test.applyTestOptions import io.sentry.test.initForTest import io.sentry.transport.NoOpEnvelopeCache @@ -64,6 +68,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import java.io.ByteArrayOutputStream import java.io.File import java.nio.file.Files import java.util.concurrent.TimeUnit @@ -417,27 +422,31 @@ class SentryAndroidTest { assertEquals("Debug!", event.breadcrumbs!![0].message) assertEquals("staging", event.environment) assertEquals("io.sentry.sample@2.0.0", event.release) + assertEquals("afcb46b1140ade5187c4bbb5daa804df", event.contexts[Contexts.REPLAY_ID]) asserted.set(true) null } // have to do it after the cacheDir is set to options, because it adds a dsn hash after prefillOptionsCache(it.cacheDirPath!!) - prefillScopeCache(it.cacheDirPath!!) + prefillScopeCache(it, it.cacheDirPath!!) it.release = "io.sentry.sample@1.1.0+220" it.environment = "debug" - // this is necessary to delay the AnrV2Integration processing to execute the configure - // scope block below (otherwise it won't be possible as scopes is no-op before .init) - it.executorService.submit { - Sentry.configureScope { scope -> - // make sure the scope values changed to test that we're still using previously - // persisted values for the old ANR events - assertEquals("TestActivity", scope.transactionName) - } - } options = it } + options.executorService.submit { + // verify we reset the persisted scope values after the init bg tasks have run to ensure + // clean state for a new process. + assertEquals( + emptyList(), + options.findPersistingScopeObserver()?.read(options, BREADCRUMBS_FILENAME, List::class.java) + ) + assertEquals( + SentryId.EMPTY_ID.toString(), + options.findPersistingScopeObserver()?.read(options, REPLAY_FILENAME, String::class.java) + ) + } Sentry.configureScope { it.setTransaction("TestActivity") it.addBreadcrumb(Breadcrumb.error("Error!")) @@ -451,7 +460,7 @@ class SentryAndroidTest { // assert that persisted values have changed assertEquals( "TestActivity", - PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String::class.java) + options.findPersistingScopeObserver()?.read(options, TRANSACTION_FILENAME, String::class.java) ) assertEquals( "io.sentry.sample@1.1.0+220", @@ -532,19 +541,22 @@ class SentryAndroidTest { assertTrue(optionsRef.eventProcessors.any { it is AnrV2EventProcessor }) } - private fun prefillScopeCache(cacheDir: String) { + private fun prefillScopeCache(options: SentryOptions, cacheDir: String) { val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() } - File(scopeDir, BREADCRUMBS_FILENAME).writeText( - """ - [{ - "timestamp": "2009-11-16T01:08:47.000Z", - "message": "Debug!", - "type": "debug", - "level": "debug" - }] - """.trimIndent() + val queueFile = QueueFile.Builder(File(scopeDir, BREADCRUMBS_FILENAME)).build() + val baos = ByteArrayOutputStream() + options.serializer.serialize( + Breadcrumb(DateUtils.getDateTime("2009-11-16T01:08:47.000Z")).apply { + message = "Debug!" + type = "debug" + level = DEBUG + }, + baos.writer() ) + queueFile.add(baos.toByteArray()) File(scopeDir, TRANSACTION_FILENAME).writeText("\"MainActivity\"") + File(scopeDir, REPLAY_FILENAME).writeText("\"afcb46b1140ade5187c4bbb5daa804df\"") + File(options.getCacheDirPath(), "replay_afcb46b1140ade5187c4bbb5daa804df").mkdirs() } private fun prefillOptionsCache(cacheDir: String) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 7d0d664e2aa..6565090e7f4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -37,7 +37,6 @@ import io.sentry.android.replay.util.appContext import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely -import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.hints.Backfillable @@ -418,7 +417,8 @@ public class ReplayIntegration( // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in MainEventProcessor options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") { - val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: run { + val persistingScopeObserver = options.findPersistingScopeObserver() + val previousReplayIdString = persistingScopeObserver?.read(options, REPLAY_FILENAME, String::class.java) ?: run { cleanupReplays() return@submitSafely } @@ -431,7 +431,9 @@ public class ReplayIntegration( cleanupReplays() return@submitSafely } - val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List + + @Suppress("UNCHECKED_CAST") + val breadcrumbs = persistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java) as? List val segment = CaptureStrategy.createSegment( scopes = scopes, options = options, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 1a07e35adaf..7ba893d4076 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -32,6 +32,7 @@ import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.cache.PersistingScopeObserver +import io.sentry.cache.tape.QueueFile import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent @@ -59,6 +60,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config +import java.io.ByteArrayOutputStream import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -456,6 +458,7 @@ class ReplayIntegrationTest { val oldReplayId = SentryId() fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) val oldReplay = File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } val screenshot = File(oldReplay, "1720693523997.jpg").also { it.createNewFile() } @@ -472,17 +475,18 @@ class ReplayIntegrationTest { it.writeText("\"$oldReplayId\"") } val breadcrumbsFile = File(scopeCache, PersistingScopeObserver.BREADCRUMBS_FILENAME) + val queueFile = QueueFile.Builder(breadcrumbsFile).build() + val baos = ByteArrayOutputStream() fixture.options.serializer.serialize( - listOf( - Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { - category = "navigation" - type = "navigation" - setData("from", "from") - setData("to", "to") - } - ), - breadcrumbsFile.writer() + Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { + category = "navigation" + type = "navigation" + setData("from", "from") + setData("to", "to") + }, + baos.writer() ) + queueFile.add(baos.toByteArray()) File(oldReplay, ONGOING_SEGMENT).also { it.writeText( """ diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2c6aaaea2cf..5115cace136 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2846,6 +2846,7 @@ public class io/sentry/SentryOptions { public fun addPerformanceCollector (Lio/sentry/IPerformanceCollector;)V public fun addScopeObserver (Lio/sentry/IScopeObserver;)V public static fun empty ()Lio/sentry/SentryOptions; + public fun findPersistingScopeObserver ()Lio/sentry/cache/PersistingScopeObserver; public fun getBackpressureMonitor ()Lio/sentry/backpressure/IBackpressureMonitor; public fun getBeforeBreadcrumb ()Lio/sentry/SentryOptions$BeforeBreadcrumbCallback; public fun getBeforeEnvelopeCallback ()Lio/sentry/SentryOptions$BeforeEnvelopeCallback; @@ -3928,8 +3929,9 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public static final field TRANSACTION_FILENAME Ljava/lang/String; public static final field USER_FILENAME Ljava/lang/String; public fun (Lio/sentry/SentryOptions;)V - public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; - public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V + public fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; + public fun resetCache ()V public fun setBreadcrumbs (Ljava/util/Collection;)V public fun setContexts (Lio/sentry/protocol/Contexts;)V public fun setExtras (Ljava/util/Map;)V @@ -3944,6 +3946,50 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public static fun store (Lio/sentry/SentryOptions;Ljava/lang/Object;Ljava/lang/String;)V } +public abstract class io/sentry/cache/tape/ObjectQueue : java/io/Closeable, java/lang/Iterable { + public fun ()V + public abstract fun add (Ljava/lang/Object;)V + public fun asList ()Ljava/util/List; + public fun clear ()V + public static fun create (Lio/sentry/cache/tape/QueueFile;Lio/sentry/cache/tape/ObjectQueue$Converter;)Lio/sentry/cache/tape/ObjectQueue; + public static fun createEmpty ()Lio/sentry/cache/tape/ObjectQueue; + public abstract fun file ()Lio/sentry/cache/tape/QueueFile; + public fun isEmpty ()Z + public abstract fun peek ()Ljava/lang/Object; + public fun peek (I)Ljava/util/List; + public fun remove ()V + public abstract fun remove (I)V + public abstract fun size ()I +} + +public abstract interface class io/sentry/cache/tape/ObjectQueue$Converter { + public abstract fun from ([B)Ljava/lang/Object; + public abstract fun toStream (Ljava/lang/Object;Ljava/io/OutputStream;)V +} + +public final class io/sentry/cache/tape/QueueFile : java/io/Closeable, java/lang/Iterable { + public fun add ([B)V + public fun add ([BII)V + public fun clear ()V + public fun close ()V + public fun file ()Ljava/io/File; + public fun isAtFullCapacity ()Z + public fun isEmpty ()Z + public fun iterator ()Ljava/util/Iterator; + public fun peek ()[B + public fun remove ()V + public fun remove (I)V + public fun size ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/cache/tape/QueueFile$Builder { + public fun (Ljava/io/File;)V + public fun build ()Lio/sentry/cache/tape/QueueFile; + public fun size (I)Lio/sentry/cache/tape/QueueFile$Builder; + public fun zero (Z)Lio/sentry/cache/tape/QueueFile$Builder; +} + public final class io/sentry/clientreport/ClientReport : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun (Ljava/util/Date;Ljava/util/List;)V public fun getDiscardedEvents ()Ljava/util/List; diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 4498e3bc2fb..bbe4c0dca35 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { testImplementation(Config.TestLibs.awaitility) testImplementation(Config.TestLibs.javaFaker) testImplementation(Config.TestLibs.msgpack) + testImplementation(Config.TestLibs.okio) testImplementation(projects.sentryTestSupport) } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 81097e4fe57..82459410a48 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -4,6 +4,7 @@ import io.sentry.backpressure.NoOpBackpressureMonitor; import io.sentry.cache.EnvelopeCache; import io.sentry.cache.IEnvelopeCache; +import io.sentry.cache.PersistingScopeObserver; import io.sentry.config.PropertiesProviderFactory; import io.sentry.internal.debugmeta.NoOpDebugMetaLoader; import io.sentry.internal.debugmeta.ResourcesDebugMetaLoader; @@ -499,6 +500,16 @@ private static void notifyOptionsObservers(final @NotNull SentryOptions options) observer.setReplayErrorSampleRate( options.getSessionReplay().getOnErrorSampleRate()); } + + // since it's a new SDK init we clean up persisted scope values before serializing + // new ones, so they are not making it to the new events if they were e.g. disabled + // (e.g. replayId) or are simply irrelevant (e.g. breadcrumbs). NOTE: this happens + // after the integrations relying on those values are done with processing them. + final @Nullable PersistingScopeObserver scopeCache = + options.findPersistingScopeObserver(); + if (scopeCache != null) { + scopeCache.resetCache(); + } }); } catch (Throwable e) { options.getLogger().log(SentryLevel.DEBUG, "Failed to notify options observers.", e); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index b82a25cb2f7..1ddb12f0772 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -4,6 +4,7 @@ import io.sentry.backpressure.IBackpressureMonitor; import io.sentry.backpressure.NoOpBackpressureMonitor; import io.sentry.cache.IEnvelopeCache; +import io.sentry.cache.PersistingScopeObserver; import io.sentry.clientreport.ClientReportRecorder; import io.sentry.clientreport.IClientReportRecorder; import io.sentry.clientreport.NoOpClientReportRecorder; @@ -1451,6 +1452,17 @@ public List getScopeObservers() { return observers; } + @ApiStatus.Internal + @Nullable + public PersistingScopeObserver findPersistingScopeObserver() { + for (final @NotNull IScopeObserver observer : observers) { + if (observer instanceof PersistingScopeObserver) { + return (PersistingScopeObserver) observer; + } + } + return null; + } + /** * Adds a SentryOptions observer * diff --git a/sentry/src/main/java/io/sentry/cache/CacheUtils.java b/sentry/src/main/java/io/sentry/cache/CacheUtils.java index 1eb5f7e19f4..eb9732a3439 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheUtils.java +++ b/sentry/src/main/java/io/sentry/cache/CacheUtils.java @@ -38,13 +38,6 @@ static void store( } final File file = new File(cacheDir, fileName); - if (file.exists()) { - options.getLogger().log(DEBUG, "Overwriting %s in scope cache", fileName); - if (!file.delete()) { - options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); - } - } - try (final OutputStream outputStream = new FileOutputStream(file); final Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) { options.getSerializer().serialize(entity, writer); @@ -64,11 +57,9 @@ static void delete( } final File file = new File(cacheDir, fileName); - if (file.exists()) { - options.getLogger().log(DEBUG, "Deleting %s from scope cache", fileName); - if (!file.delete()) { - options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); - } + options.getLogger().log(DEBUG, "Deleting %s from scope cache", fileName); + if (!file.delete()) { + options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); } } @@ -102,7 +93,7 @@ static void delete( return null; } - private static @Nullable File ensureCacheDir( + static @Nullable File ensureCacheDir( final @NotNull SentryOptions options, final @NotNull String cacheDirName) { final String cacheDir = options.getCacheDirPath(); if (cacheDir == null) { diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 908e2c66e41..c9356579c9c 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -1,18 +1,33 @@ package io.sentry.cache; import static io.sentry.SentryLevel.ERROR; +import static io.sentry.SentryLevel.INFO; +import static io.sentry.cache.CacheUtils.ensureCacheDir; import io.sentry.Breadcrumb; import io.sentry.IScope; -import io.sentry.JsonDeserializer; import io.sentry.ScopeObserverAdapter; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SpanContext; +import io.sentry.cache.tape.ObjectQueue; +import io.sentry.cache.tape.QueueFile; import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; +import io.sentry.util.LazyEvaluator; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.Charset; import java.util.Collection; import java.util.Map; import org.jetbrains.annotations.NotNull; @@ -20,6 +35,8 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { + private static final Charset UTF_8 = Charset.forName("UTF-8"); + public static final String SCOPE_CACHE = ".scope-cache"; public static final String USER_FILENAME = "user.json"; public static final String BREADCRUMBS_FILENAME = "breadcrumbs.json"; @@ -33,7 +50,60 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { public static final String TRACE_FILENAME = "trace.json"; public static final String REPLAY_FILENAME = "replay.json"; - private final @NotNull SentryOptions options; + private @NotNull SentryOptions options; + private final @NotNull LazyEvaluator> breadcrumbsQueue = + new LazyEvaluator<>( + () -> { + final File cacheDir = ensureCacheDir(options, SCOPE_CACHE); + if (cacheDir == null) { + options.getLogger().log(INFO, "Cache dir is not set, cannot store in scope cache"); + return ObjectQueue.createEmpty(); + } + + QueueFile queueFile = null; + final File file = new File(cacheDir, BREADCRUMBS_FILENAME); + try { + try { + queueFile = new QueueFile.Builder(file).size(options.getMaxBreadcrumbs()).build(); + } catch (IOException e) { + // if file is corrupted we simply delete it and try to create it again. We accept + // the trade + // off of losing breadcrumbs for ANRs that happened right before the app has + // received an + // update where the new format was introduced + file.delete(); + + queueFile = new QueueFile.Builder(file).size(options.getMaxBreadcrumbs()).build(); + } + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to create breadcrumbs queue", e); + return ObjectQueue.createEmpty(); + } + return ObjectQueue.create( + queueFile, + new ObjectQueue.Converter() { + @Override + @Nullable + public Breadcrumb from(byte[] source) { + try (final Reader reader = + new BufferedReader( + new InputStreamReader(new ByteArrayInputStream(source), UTF_8))) { + return options.getSerializer().deserialize(reader, Breadcrumb.class); + } catch (Throwable e) { + options.getLogger().log(ERROR, e, "Error reading entity from scope cache"); + } + return null; + } + + @Override + public void toStream(Breadcrumb value, OutputStream sink) throws IOException { + try (final Writer writer = + new BufferedWriter(new OutputStreamWriter(sink, UTF_8))) { + options.getSerializer().serialize(value, writer); + } + } + }); + }); public PersistingScopeObserver(final @NotNull SentryOptions options) { this.options = options; @@ -51,9 +121,32 @@ public void setUser(final @Nullable User user) { }); } + @Override + public void addBreadcrumb(@NotNull Breadcrumb crumb) { + serializeToDisk( + () -> { + try { + breadcrumbsQueue.getValue().add(crumb); + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to add breadcrumb to file queue", e); + } + }); + } + @Override public void setBreadcrumbs(@NotNull Collection breadcrumbs) { - serializeToDisk(() -> store(breadcrumbs, BREADCRUMBS_FILENAME)); + if (breadcrumbs.isEmpty()) { + // we only clear the queue if the new collection is empty (someone called clearBreadcrumbs) + // If it's not empty, we'd add breadcrumbs one-by-one in the method above + serializeToDisk( + () -> { + try { + breadcrumbsQueue.getValue().clear(); + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to clear breadcrumbs from file queue", e); + } + }); + } } @Override @@ -133,9 +226,16 @@ public void setReplayId(@NotNull SentryId replayId) { @SuppressWarnings("FutureReturnValueIgnored") private void serializeToDisk(final @NotNull Runnable task) { + if (!options.isEnableScopePersistence()) { + return; + } if (Thread.currentThread().getName().contains("SentryExecutor")) { // we're already on the sentry executor thread, so we can just execute it directly - task.run(); + try { + task.run(); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Serialization task failed", e); + } return; } @@ -170,18 +270,42 @@ public static void store( CacheUtils.store(options, entity, SCOPE_CACHE, fileName); } - public static @Nullable T read( + public @Nullable T read( final @NotNull SentryOptions options, final @NotNull String fileName, final @NotNull Class clazz) { - return read(options, fileName, clazz, null); + if (fileName.equals(BREADCRUMBS_FILENAME)) { + try { + return clazz.cast(breadcrumbsQueue.getValue().asList()); + } catch (IOException e) { + options.getLogger().log(ERROR, "Unable to read serialized breadcrumbs from QueueFile"); + return null; + } + } + return CacheUtils.read(options, SCOPE_CACHE, fileName, clazz, null); } - public static @Nullable T read( - final @NotNull SentryOptions options, - final @NotNull String fileName, - final @NotNull Class clazz, - final @Nullable JsonDeserializer elementDeserializer) { - return CacheUtils.read(options, SCOPE_CACHE, fileName, clazz, elementDeserializer); + /** + * Resets the scope cache by deleting the files and/or clearing the QueueFiles. Note: this does + * I/O and should be called from a background thread. + */ + public void resetCache() { + // since it keeps a reference to the file and we cannot delete it, breadcrumbs we just clear + try { + breadcrumbsQueue.getValue().clear(); + } catch (IOException e) { + options.getLogger().log(ERROR, "Failed to clear breadcrumbs from file queue", e); + } + + // the rest we can safely delete + delete(USER_FILENAME); + delete(LEVEL_FILENAME); + delete(REQUEST_FILENAME); + delete(FINGERPRINT_FILENAME); + delete(CONTEXTS_FILENAME); + delete(EXTRAS_FILENAME); + delete(TAGS_FILENAME); + delete(TRACE_FILENAME); + delete(TRANSACTION_FILENAME); } } diff --git a/sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java b/sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java new file mode 100644 index 00000000000..2aa41c9b791 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/EmptyObjectQueue.java @@ -0,0 +1,52 @@ +package io.sentry.cache.tape; + +import java.io.IOException; +import java.util.Iterator; +import java.util.NoSuchElementException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class EmptyObjectQueue extends ObjectQueue { + @Override + public @Nullable QueueFile file() { + return null; + } + + @Override + public int size() { + return 0; + } + + @Override + public void add(T entry) throws IOException {} + + @Override + public @Nullable T peek() throws IOException { + return null; + } + + @Override + public void remove(int n) throws IOException {} + + @Override + public void close() throws IOException {} + + @NotNull + @Override + public Iterator iterator() { + return new EmptyIterator<>(); + } + + private static final class EmptyIterator implements Iterator { + + @Override + public boolean hasNext() { + return false; + } + + @Override + public T next() { + throw new NoSuchElementException("No elements in EmptyIterator!"); + } + } +} diff --git a/sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java b/sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java new file mode 100644 index 00000000000..8ed9cef56e1 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/FileObjectQueue.java @@ -0,0 +1,148 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/main/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Iterator; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class FileObjectQueue extends ObjectQueue { + /** Backing storage implementation. */ + private final QueueFile queueFile; + /** Reusable byte output buffer. */ + private final DirectByteArrayOutputStream bytes = new DirectByteArrayOutputStream(); + + final Converter converter; + + FileObjectQueue(QueueFile queueFile, Converter converter) { + this.queueFile = queueFile; + this.converter = converter; + } + + @Override + public @NotNull QueueFile file() { + return queueFile; + } + + @Override + public int size() { + return queueFile.size(); + } + + @Override + public boolean isEmpty() { + return queueFile.isEmpty(); + } + + @Override + public void add(T entry) throws IOException { + bytes.reset(); + converter.toStream(entry, bytes); + queueFile.add(bytes.getArray(), 0, bytes.size()); + } + + @Override + public @Nullable T peek() throws IOException { + byte[] bytes = queueFile.peek(); + if (bytes == null) return null; + return converter.from(bytes); + } + + @Override + public void remove() throws IOException { + queueFile.remove(); + } + + @Override + public void remove(int n) throws IOException { + queueFile.remove(n); + } + + @Override + public void clear() throws IOException { + queueFile.clear(); + } + + @Override + public void close() throws IOException { + queueFile.close(); + } + + /** + * Returns an iterator over entries in this queue. + * + *

The iterator disallows modifications to the queue during iteration. Removing entries from + * the head of the queue is permitted during iteration using {@link Iterator#remove()}. + * + *

The iterator may throw an unchecked {@link IOException} during {@link Iterator#next()} or + * {@link Iterator#remove()}. + */ + @Override + public Iterator iterator() { + return new QueueFileIterator(queueFile.iterator()); + } + + @Override + public String toString() { + return "FileObjectQueue{" + "queueFile=" + queueFile + '}'; + } + + private final class QueueFileIterator implements Iterator { + final Iterator iterator; + + QueueFileIterator(Iterator iterator) { + this.iterator = iterator; + } + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + @Nullable + public T next() { + byte[] data = iterator.next(); + try { + return converter.from(data); + } catch (IOException e) { + throw QueueFile.getSneakyThrowable(e); + } + } + + @Override + public void remove() { + iterator.remove(); + } + } + + /** Enables direct access to the internal array. Avoids unnecessary copying. */ + private static final class DirectByteArrayOutputStream extends ByteArrayOutputStream { + DirectByteArrayOutputStream() {} + + /** + * Gets a reference to the internal byte array. The {@link #size()} method indicates how many + * bytes contain actual data added since the last {@link #reset()} call. + */ + byte[] getArray() { + return buf; + } + } +} diff --git a/sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java b/sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java new file mode 100644 index 00000000000..c92cad36218 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/ObjectQueue.java @@ -0,0 +1,108 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/main/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape; + +import java.io.Closeable; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** A queue of objects. */ +@ApiStatus.Internal +public abstract class ObjectQueue implements Iterable, Closeable { + /** A queue for objects that are atomically and durably serialized to {@code file}. */ + public static ObjectQueue create(QueueFile qf, Converter converter) { + return new FileObjectQueue<>(qf, converter); + } + + /** An empty queue for objects that is essentially a no-op. */ + public static ObjectQueue createEmpty() { + return new EmptyObjectQueue<>(); + } + + /** The underlying {@link QueueFile} backing this queue, or null if it's only in memory. */ + public abstract @Nullable QueueFile file(); + + /** Returns the number of entries in the queue. */ + public abstract int size(); + + /** Returns {@code true} if this queue contains no entries. */ + public boolean isEmpty() { + return size() == 0; + } + + /** Enqueues an entry that can be processed at any time. */ + public abstract void add(T entry) throws IOException; + + /** + * Returns the head of the queue, or {@code null} if the queue is empty. Does not modify the + * queue. + */ + public abstract @Nullable T peek() throws IOException; + + /** + * Reads up to {@code max} entries from the head of the queue without removing the entries. If the + * queue's {@link #size()} is less than {@code max} then only {@link #size()} entries are read. + */ + public List peek(int max) throws IOException { + int end = Math.min(max, size()); + List subList = new ArrayList(end); + Iterator iterator = iterator(); + for (int i = 0; i < end; i++) { + subList.add(iterator.next()); + } + return Collections.unmodifiableList(subList); + } + + /** Returns the entries in the queue as an unmodifiable {@link List}. */ + public List asList() throws IOException { + return peek(size()); + } + + /** Removes the head of the queue. */ + public void remove() throws IOException { + remove(1); + } + + /** Removes {@code n} entries from the head of the queue. */ + public abstract void remove(int n) throws IOException; + + /** Clears this queue. Also truncates the file to the initial size. */ + public void clear() throws IOException { + remove(size()); + } + + /** + * Convert a byte stream to and from a concrete type. + * + * @param Object type. + */ + public interface Converter { + /** Converts bytes to an object. */ + @Nullable + T from(byte[] source) throws IOException; + + /** Converts {@code value} to bytes written to the specified stream. */ + void toStream(T value, OutputStream sink) throws IOException; + } +} diff --git a/sentry/src/main/java/io/sentry/cache/tape/QueueFile.java b/sentry/src/main/java/io/sentry/cache/tape/QueueFile.java new file mode 100644 index 00000000000..bc2ed568267 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/tape/QueueFile.java @@ -0,0 +1,817 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/main/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape; + +import static java.lang.Math.min; + +import java.io.Closeable; +import java.io.EOFException; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; +import java.util.ConcurrentModificationException; +import java.util.Iterator; +import java.util.NoSuchElementException; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** + * A reliable, efficient, file-based, FIFO queue. Additions and removals are O(1). All operations + * are atomic. Writes are synchronous; data will be written to disk before an operation returns. The + * underlying file is structured to survive process and even system crashes. If an I/O exception is + * thrown during a mutating change, the change is aborted. It is safe to continue to use a {@code + * QueueFile} instance after an exception. + * + *

Note that this implementation is not synchronized. + * + *

In a traditional queue, the remove operation returns an element. In this queue, {@link #peek} + * and {@link #remove} are used in conjunction. Use {@code peek} to retrieve the first element, and + * then {@code remove} to remove it after successful processing. If the system crashes after {@code + * peek} and during processing, the element will remain in the queue, to be processed when the + * system restarts. + * + *

NOTE: The current implementation is built for file systems that support + * atomic segment writes (like YAFFS). Most conventional file systems don't support this; if the + * power goes out while writing a segment, the segment will contain garbage and the file will be + * corrupt. We'll add journaling support so this class can be used with more file systems later. + * + *

Construct instances with {@link Builder}. + * + * @author Bob Lee (bob@squareup.com) + */ +@ApiStatus.Internal +public final class QueueFile implements Closeable, Iterable { + /** Leading bit set to 1 indicating a versioned header and the version of 1. */ + private static final int VERSIONED_HEADER = 0x80000001; + + /** Initial file size in bytes. */ + static final int INITIAL_LENGTH = 4096; // one file system block + + /** A block of nothing to write over old data. */ + private static final byte[] ZEROES = new byte[INITIAL_LENGTH]; + + /** + * The underlying file. Uses a ring buffer to store entries. Designed so that a modification isn't + * committed or visible until we write the header. The header is much smaller than a segment. So + * long as the underlying file system supports atomic segment writes, changes to the queue are + * atomic. Storing the file length ensures we can recover from a failed expansion (i.e. if setting + * the file length succeeds but the process dies before the data can be copied). + * + *

This implementation supports two versions of the on-disk format. + * + *

+   * Format:
+   *   16-32 bytes      Header
+   *   ...              Data
+   *
+   * Header (32 bytes):
+   *   1 bit            Versioned indicator [0 = legacy (see "Legacy Header"), 1 = versioned]
+   *   31 bits          Version, always 1
+   *   8 bytes          File length
+   *   4 bytes          Element count
+   *   8 bytes          Head element position
+   *   8 bytes          Tail element position
+   *
+   * Element:
+   *   4 bytes          Data length
+   *   ...              Data
+   * 
+ */ + RandomAccessFile raf; + + /** Keep file around for error reporting. */ + final File file; + + /** The header length in bytes: 16 or 32. */ + final int headerLength = 32; + + /** Cached file length. Always a power of 2. */ + long fileLength; + + /** Number of elements. */ + int elementCount; + + /** Pointer to first (or eldest) element. */ + Element first; + + /** Pointer to last (or newest) element. */ + private Element last; + + /** In-memory buffer. Big enough to hold the header. */ + private final byte[] buffer = new byte[32]; + + /** + * The number of times this file has been structurally modified — it is incremented during {@link + * #remove(int)} and {@link #add(byte[], int, int)}. Used by {@link ElementIterator} to guard + * against concurrent modification. + */ + int modCount = 0; + + /** When true, removing an element will also overwrite data with zero bytes. */ + private final boolean zero; + + /** A number of elements at which this queue will wrap around (ring buffer). */ + private final int maxElements; + + boolean closed; + + static RandomAccessFile initializeFromFile(File file) throws IOException { + if (!file.exists()) { + // Use a temp file so we don't leave a partially-initialized file. + File tempFile = new File(file.getPath() + ".tmp"); + RandomAccessFile raf = open(tempFile); + try { + raf.setLength(INITIAL_LENGTH); + raf.seek(0); + raf.writeInt(VERSIONED_HEADER); + raf.writeLong(INITIAL_LENGTH); + } finally { + raf.close(); + } + + // A rename is atomic. + if (!tempFile.renameTo(file)) { + throw new IOException("Rename failed!"); + } + } + + return open(file); + } + + /** Opens a random access file that writes synchronously. */ + private static RandomAccessFile open(File file) throws FileNotFoundException { + return new RandomAccessFile(file, "rwd"); + } + + QueueFile(File file, RandomAccessFile raf, boolean zero, int maxElements) throws IOException { + this.file = file; + this.raf = raf; + this.zero = zero; + this.maxElements = maxElements; + + readInitialData(); + } + + private void readInitialData() throws IOException { + raf.seek(0); + raf.readFully(buffer); + + long firstOffset; + long lastOffset; + + fileLength = readLong(buffer, 4); + elementCount = readInt(buffer, 12); + firstOffset = readLong(buffer, 16); + lastOffset = readLong(buffer, 24); + + if (fileLength > raf.length()) { + throw new IOException( + "File is truncated. Expected length: " + fileLength + ", Actual length: " + raf.length()); + } else if (fileLength <= headerLength) { + throw new IOException( + "File is corrupt; length stored in header (" + fileLength + ") is invalid."); + } + + first = readElement(firstOffset); + last = readElement(lastOffset); + } + + private void resetFile() throws IOException { + raf.close(); + file.delete(); + raf = initializeFromFile(file); + readInitialData(); + } + + /** + * Stores an {@code int} in the {@code byte[]}. The behavior is equivalent to calling {@link + * RandomAccessFile#writeInt}. + */ + private static void writeInt(byte[] buffer, int offset, int value) { + buffer[offset] = (byte) (value >> 24); + buffer[offset + 1] = (byte) (value >> 16); + buffer[offset + 2] = (byte) (value >> 8); + buffer[offset + 3] = (byte) value; + } + + /** Reads an {@code int} from the {@code byte[]}. */ + private static int readInt(byte[] buffer, int offset) { + return ((buffer[offset] & 0xff) << 24) + + ((buffer[offset + 1] & 0xff) << 16) + + ((buffer[offset + 2] & 0xff) << 8) + + (buffer[offset + 3] & 0xff); + } + + /** + * Stores an {@code long} in the {@code byte[]}. The behavior is equivalent to calling {@link + * RandomAccessFile#writeLong}. + */ + private static void writeLong(byte[] buffer, int offset, long value) { + buffer[offset] = (byte) (value >> 56); + buffer[offset + 1] = (byte) (value >> 48); + buffer[offset + 2] = (byte) (value >> 40); + buffer[offset + 3] = (byte) (value >> 32); + buffer[offset + 4] = (byte) (value >> 24); + buffer[offset + 5] = (byte) (value >> 16); + buffer[offset + 6] = (byte) (value >> 8); + buffer[offset + 7] = (byte) value; + } + + /** Reads an {@code long} from the {@code byte[]}. */ + private static long readLong(byte[] buffer, int offset) { + return ((buffer[offset] & 0xffL) << 56) + + ((buffer[offset + 1] & 0xffL) << 48) + + ((buffer[offset + 2] & 0xffL) << 40) + + ((buffer[offset + 3] & 0xffL) << 32) + + ((buffer[offset + 4] & 0xffL) << 24) + + ((buffer[offset + 5] & 0xffL) << 16) + + ((buffer[offset + 6] & 0xffL) << 8) + + (buffer[offset + 7] & 0xffL); + } + + /** + * Writes header atomically. The arguments contain the updated values. The class member fields + * should not have changed yet. This only updates the state in the file. It's up to the caller to + * update the class member variables *after* this call succeeds. Assumes segment writes are atomic + * in the underlying file system. + */ + private void writeHeader(long fileLength, int elementCount, long firstPosition, long lastPosition) + throws IOException { + raf.seek(0); + + writeInt(buffer, 0, VERSIONED_HEADER); + writeLong(buffer, 4, fileLength); + writeInt(buffer, 12, elementCount); + writeLong(buffer, 16, firstPosition); + writeLong(buffer, 24, lastPosition); + raf.write(buffer, 0, 32); + } + + Element readElement(long position) throws IOException { + if (position == 0) return Element.NULL; + boolean success = ringRead(position, buffer, 0, Element.HEADER_LENGTH); + if (!success) { + return Element.NULL; + } + int length = readInt(buffer, 0); + return new Element(position, length); + } + + /** Wraps the position if it exceeds the end of the file. */ + long wrapPosition(long position) { + return position < fileLength ? position : headerLength + position - fileLength; + } + + /** + * Writes count bytes from buffer to position in file. Automatically wraps write if position is + * past the end of the file or if buffer overlaps it. + * + * @param position in file to write to + * @param buffer to write from + * @param count # of bytes to write + */ + private void ringWrite(long position, byte[] buffer, int offset, int count) throws IOException { + position = wrapPosition(position); + if (position + count <= fileLength) { + raf.seek(position); + raf.write(buffer, offset, count); + } else { + // The write overlaps the EOF. + // # of bytes to write before the EOF. Guaranteed to be less than Integer.MAX_VALUE. + int beforeEof = (int) (fileLength - position); + raf.seek(position); + raf.write(buffer, offset, beforeEof); + raf.seek(headerLength); + raf.write(buffer, offset + beforeEof, count - beforeEof); + } + } + + private void ringErase(long position, long length) throws IOException { + while (length > 0) { + int chunk = (int) min(length, ZEROES.length); + ringWrite(position, ZEROES, 0, chunk); + length -= chunk; + position += chunk; + } + } + + /** + * Reads count bytes into buffer from file. Wraps if necessary. + * + * @param position in file to read from + * @param buffer to read into + * @param count # of bytes to read + * @return true if the read was successful, false if the file is corrupt + */ + boolean ringRead(long position, byte[] buffer, int offset, int count) throws IOException { + try { + position = wrapPosition(position); + if (position + count <= fileLength) { + raf.seek(position); + raf.readFully(buffer, offset, count); + } else { + // The read overlaps the EOF. + // # of bytes to read before the EOF. Guaranteed to be less than Integer.MAX_VALUE. + int beforeEof = (int) (fileLength - position); + raf.seek(position); + raf.readFully(buffer, offset, beforeEof); + raf.seek(headerLength); + raf.readFully(buffer, offset + beforeEof, count - beforeEof); + } + return true; + } catch (EOFException e) { + // since EOFException inherits from IOException, we need to catch it explicitly + // and reset the file + resetFile(); + } catch (IOException e) { + throw e; + } catch (Throwable e) { + // most likely the file is corrupt, so we delete it and recreate, accepting data loss + resetFile(); + } + return false; + } + + /** + * Adds an element to the end of the queue. + * + * @param data to copy bytes from + */ + public void add(byte[] data) throws IOException { + add(data, 0, data.length); + } + + /** + * Adds an element to the end of the queue. + * + * @param data to copy bytes from + * @param offset to start from in buffer + * @param count number of bytes to copy + * @throws IndexOutOfBoundsException if {@code offset < 0} or {@code count < 0}, or if {@code + * offset + count} is bigger than the length of {@code buffer}. + */ + public void add(byte[] data, int offset, int count) throws IOException { + if (data == null) { + throw new NullPointerException("data == null"); + } + if ((offset | count) < 0 || count > data.length - offset) { + throw new IndexOutOfBoundsException(); + } + if (closed) throw new IllegalStateException("closed"); + + // If the queue is at full capacity, remove the oldest element first. + if (isAtFullCapacity()) { + remove(); + } + + expandIfNecessary(count); + + // Insert a new element after the current last element. + boolean wasEmpty = isEmpty(); + long position = + wasEmpty ? headerLength : wrapPosition(last.position + Element.HEADER_LENGTH + last.length); + Element newLast = new Element(position, count); + + // Write length. + writeInt(buffer, 0, count); + ringWrite(newLast.position, buffer, 0, Element.HEADER_LENGTH); + + // Write data. + ringWrite(newLast.position + Element.HEADER_LENGTH, data, offset, count); + + // Commit the addition. If wasEmpty, first == last. + long firstPosition = wasEmpty ? newLast.position : first.position; + writeHeader(fileLength, elementCount + 1, firstPosition, newLast.position); + last = newLast; + elementCount++; + modCount++; + if (wasEmpty) first = last; // first element + } + + private long usedBytes() { + if (elementCount == 0) return headerLength; + + if (last.position >= first.position) { + // Contiguous queue. + return (last.position - first.position) // all but last entry + + Element.HEADER_LENGTH + + last.length // last entry + + headerLength; + } else { + // tail < head. The queue wraps. + return last.position // buffer front + header + + Element.HEADER_LENGTH + + last.length // last entry + + fileLength + - first.position; // buffer end + } + } + + private long remainingBytes() { + return fileLength - usedBytes(); + } + + /** Returns true if this queue contains no entries. */ + public boolean isEmpty() { + return elementCount == 0; + } + + /** + * If necessary, expands the file to accommodate an additional element of the given length. + * + * @param dataLength length of data being added + */ + private void expandIfNecessary(long dataLength) throws IOException { + long elementLength = Element.HEADER_LENGTH + dataLength; + long remainingBytes = remainingBytes(); + if (remainingBytes >= elementLength) return; + + // Expand. + long previousLength = fileLength; + long newLength; + // Double the length until we can fit the new data. + do { + remainingBytes += previousLength; + newLength = previousLength << 1; + previousLength = newLength; + } while (remainingBytes < elementLength); + + setLength(newLength); + + // Calculate the position of the tail end of the data in the ring buffer + long endOfLastElement = wrapPosition(last.position + Element.HEADER_LENGTH + last.length); + long count = 0; + // If the buffer is split, we need to make it contiguous + if (endOfLastElement <= first.position) { + FileChannel channel = raf.getChannel(); + channel.position(fileLength); // destination position + count = endOfLastElement - headerLength; + if (channel.transferTo(headerLength, count, channel) != count) { + throw new AssertionError("Copied insufficient number of bytes!"); + } + } + + // Commit the expansion. + if (last.position < first.position) { + long newLastPosition = fileLength + last.position - headerLength; + writeHeader(newLength, elementCount, first.position, newLastPosition); + last = new Element(newLastPosition, last.length); + } else { + writeHeader(newLength, elementCount, first.position, last.position); + } + + fileLength = newLength; + + if (zero) { + ringErase(headerLength, count); + } + } + + /** Sets the length of the file. */ + private void setLength(long newLength) throws IOException { + // Set new file length (considered metadata) and sync it to storage. + raf.setLength(newLength); + raf.getChannel().force(true); + } + + /** Reads the eldest element. Returns null if the queue is empty. */ + public @Nullable byte[] peek() throws IOException { + if (closed) throw new IllegalStateException("closed"); + if (isEmpty()) return null; + int length = first.length; + byte[] data = new byte[length]; + boolean success = ringRead(first.position + Element.HEADER_LENGTH, data, 0, length); + return success ? data : null; + } + + /** + * Returns an iterator over elements in this QueueFile. + * + *

The iterator disallows modifications to be made to the QueueFile during iteration. Removing + * elements from the head of the QueueFile is permitted during iteration using {@link + * Iterator#remove()}. + * + *

The iterator may throw an unchecked {@link IOException} during {@link Iterator#next()} or + * {@link Iterator#remove()}. + */ + @Override + public Iterator iterator() { + return new ElementIterator(); + } + + private final class ElementIterator implements Iterator { + /** Index of element to be returned by subsequent call to next. */ + int nextElementIndex = 0; + + /** Position of element to be returned by subsequent call to next. */ + private long nextElementPosition = first.position; + + /** + * The {@link #modCount} value that the iterator believes that the backing QueueFile should + * have. If this expectation is violated, the iterator has detected concurrent modification. + */ + int expectedModCount = modCount; + + ElementIterator() {} + + private void checkForComodification() { + if (modCount != expectedModCount) throw new ConcurrentModificationException(); + } + + @Override + public boolean hasNext() { + if (closed) throw new IllegalStateException("closed"); + checkForComodification(); + return nextElementIndex != elementCount; + } + + @Override + public byte[] next() { + if (closed) throw new IllegalStateException("closed"); + checkForComodification(); + if (isEmpty()) throw new NoSuchElementException(); + if (nextElementIndex >= elementCount) throw new NoSuchElementException(); + + try { + // Read the current element. + Element current = readElement(nextElementPosition); + byte[] buffer = new byte[current.length]; + nextElementPosition = wrapPosition(current.position + Element.HEADER_LENGTH); + boolean success = ringRead(nextElementPosition, buffer, 0, current.length); + if (!success) { + // make it run out of bounds immediately + nextElementIndex = elementCount; + return ZEROES; + } + + // Update the pointer to the next element. + nextElementPosition = + wrapPosition(current.position + Element.HEADER_LENGTH + current.length); + nextElementIndex++; + + // Return the read element. + return buffer; + } catch (IOException e) { + throw QueueFile.getSneakyThrowable(e); + } catch (OutOfMemoryError e) { + // most likely the file is corrupted, so we delete it and recreate, accepting data loss + try { + resetFile(); + // make it run out of bounds immediately + nextElementIndex = elementCount; + } catch (IOException ex) { + throw QueueFile.getSneakyThrowable(ex); + } + return ZEROES; + } + } + + @Override + public void remove() { + checkForComodification(); + + if (isEmpty()) throw new NoSuchElementException(); + if (nextElementIndex != 1) { + throw new UnsupportedOperationException("Removal is only permitted from the head."); + } + + try { + QueueFile.this.remove(); + } catch (IOException e) { + throw QueueFile.getSneakyThrowable(e); + } + + expectedModCount = modCount; + nextElementIndex--; + } + } + + /** Returns the number of elements in this queue. */ + public int size() { + return elementCount; + } + + /** + * Removes the eldest element. + * + * @throws NoSuchElementException if the queue is empty + */ + public void remove() throws IOException { + remove(1); + } + + /** + * Removes the eldest {@code n} elements. + * + * @throws NoSuchElementException if the queue is empty + */ + public void remove(int n) throws IOException { + if (n < 0) { + throw new IllegalArgumentException("Cannot remove negative (" + n + ") number of elements."); + } + if (n == 0) { + return; + } + if (n == elementCount) { + clear(); + return; + } + if (isEmpty()) { + throw new NoSuchElementException(); + } + if (n > elementCount) { + throw new IllegalArgumentException( + "Cannot remove more elements (" + n + ") than present in queue (" + elementCount + ")."); + } + + long eraseStartPosition = first.position; + long eraseTotalLength = 0; + + // Read the position and length of the new first element. + long newFirstPosition = first.position; + int newFirstLength = first.length; + for (int i = 0; i < n; i++) { + eraseTotalLength += Element.HEADER_LENGTH + newFirstLength; + newFirstPosition = wrapPosition(newFirstPosition + Element.HEADER_LENGTH + newFirstLength); + boolean success = ringRead(newFirstPosition, buffer, 0, Element.HEADER_LENGTH); + if (!success) { + return; + } + newFirstLength = readInt(buffer, 0); + } + + // Commit the header. + writeHeader(fileLength, elementCount - n, newFirstPosition, last.position); + elementCount -= n; + modCount++; + first = new Element(newFirstPosition, newFirstLength); + + if (zero) { + ringErase(eraseStartPosition, eraseTotalLength); + } + } + + /** Clears this queue. Truncates the file to the initial size. */ + public void clear() throws IOException { + if (closed) throw new IllegalStateException("closed"); + + // Commit the header. + writeHeader(INITIAL_LENGTH, 0, 0, 0); + + if (zero) { + // Zero out data. + raf.seek(headerLength); + raf.write(ZEROES, 0, INITIAL_LENGTH - headerLength); + } + + elementCount = 0; + first = Element.NULL; + last = Element.NULL; + if (fileLength > INITIAL_LENGTH) setLength(INITIAL_LENGTH); + fileLength = INITIAL_LENGTH; + modCount++; + } + + /** + * Returns {@code true} if the capacity limit of this queue has been reached, i.e. the number of + * elements stored in the queue equals its maximum size. + * + * @return {@code true} if the capacity limit has been reached, {@code false} otherwise + */ + public boolean isAtFullCapacity() { + if (maxElements == -1) { + // unspecified + return false; + } + return size() == maxElements; + } + + /** The underlying {@link File} backing this queue. */ + public File file() { + return file; + } + + @Override + public void close() throws IOException { + closed = true; + raf.close(); + } + + @Override + public String toString() { + return "QueueFile{" + + "file=" + + file + + ", zero=" + + zero + + ", length=" + + fileLength + + ", size=" + + elementCount + + ", first=" + + first + + ", last=" + + last + + '}'; + } + + /** A pointer to an element. */ + static final class Element { + static final Element NULL = new Element(0, 0); + + /** Length of element header in bytes. */ + static final int HEADER_LENGTH = 4; + + /** Position in file. */ + final long position; + + /** The length of the data. */ + final int length; + + /** + * Constructs a new element. + * + * @param position within file + * @param length of data + */ + Element(long position, int length) { + this.position = position; + this.length = length; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "[position=" + position + ", length=" + length + "]"; + } + } + + /** Fluent API for creating {@link QueueFile} instances. */ + public static final class Builder { + final File file; + boolean zero = true; + int size = -1; + + /** Start constructing a new queue backed by the given file. */ + public Builder(File file) { + if (file == null) { + throw new NullPointerException("file == null"); + } + this.file = file; + } + + /** When true, removing an element will also overwrite data with zero bytes. */ + public Builder zero(boolean zero) { + this.zero = zero; + return this; + } + + /** The maximum number of elements this queue can hold before wrapping around. */ + public Builder size(int size) { + this.size = size; + return this; + } + + /** + * Constructs a new queue backed by the given builder. Only one instance should access a given + * file at a time. + */ + public QueueFile build() throws IOException { + RandomAccessFile raf = initializeFromFile(file); + QueueFile qf = null; + try { + qf = new QueueFile(file, raf, zero, size); + return qf; + } finally { + if (qf == null) { + raf.close(); + } + } + } + } + + /** + * Use this to throw checked exceptions from iterator methods that do not declare that they throw + * checked exceptions. + */ + @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) + static T getSneakyThrowable(Throwable t) throws T { + throw (T) t; + } +} diff --git a/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt b/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt index ba42810cd95..daf60e679d5 100644 --- a/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt @@ -34,6 +34,16 @@ internal class CacheUtilsTest { ) assertEquals("\"Hallo!\"", file.readText()) + + // test overwrite + CacheUtils.store( + SentryOptions().apply { cacheDirPath = cacheDir }, + "Hallo 2!", + "stuff", + "test.json" + ) + + assertEquals("\"Hallo 2!\"", file.readText()) } @Test diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt index e1927438e59..631e18cf1d5 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt @@ -2,7 +2,6 @@ package io.sentry.cache import io.sentry.Breadcrumb import io.sentry.DateUtils -import io.sentry.JsonDeserializer import io.sentry.Scope import io.sentry.SentryLevel import io.sentry.SentryOptions @@ -56,13 +55,12 @@ class DeletedEntityProvider(private val provider: (Scope) -> T?) { } @RunWith(Parameterized::class) -class PersistingScopeObserverTest( +class PersistingScopeObserverTest( private val entity: T, private val store: StoreScopeValue, private val filename: String, private val delete: DeleteScopeValue, - private val deletedEntity: DeletedEntityProvider, - private val elementDeserializer: JsonDeserializer? + private val deletedEntity: DeletedEntityProvider ) { @get:Rule @@ -89,19 +87,19 @@ class PersistingScopeObserverTest( val sut = fixture.getSut(tmpDir) store(entity, sut, fixture.scope) - val persisted = read() + val persisted = sut.read() assertEquals(entity, persisted) delete(sut, fixture.scope) - val persistedAfterDeletion = read() + val persistedAfterDeletion = sut.read() assertEquals(deletedEntity(fixture.scope), persistedAfterDeletion) } - private fun read(): T? = PersistingScopeObserver.read( + private fun PersistingScopeObserver.read(): Any? = read( fixture.options, filename, - entity!!::class.java, - elementDeserializer + // need to cast breadcrumbs to a regular List, not kotlin lists + if (entity!!::class.java.name.contains("List")) List::class.java else entity!!::class.java ) companion object { @@ -115,8 +113,7 @@ class PersistingScopeObserverTest( StoreScopeValue { user, _ -> setUser(user) }, USER_FILENAME, DeleteScopeValue { setUser(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun breadcrumbs(): Array = arrayOf( @@ -124,11 +121,29 @@ class PersistingScopeObserverTest( Breadcrumb.navigation("one", "two"), Breadcrumb.userInteraction("click", "viewId", "viewClass") ), - StoreScopeValue> { breadcrumbs, _ -> setBreadcrumbs(breadcrumbs) }, + StoreScopeValue> { breadcrumbs, _ -> + breadcrumbs.forEach { addBreadcrumb(it) } + }, + BREADCRUMBS_FILENAME, + DeleteScopeValue { setBreadcrumbs(emptyList()) }, + DeletedEntityProvider { emptyList() } + ) + + private fun legacyBreadcrumbs(): Array = arrayOf( + emptyList(), + StoreScopeValue> { _, scope -> + PersistingScopeObserver.store( + scope.options, + listOf( + Breadcrumb.navigation("one", "two"), + Breadcrumb.userInteraction("click", "viewId", "viewClass") + ), + BREADCRUMBS_FILENAME + ) + }, BREADCRUMBS_FILENAME, DeleteScopeValue { setBreadcrumbs(emptyList()) }, - DeletedEntityProvider { emptyList() }, - Breadcrumb.Deserializer() + DeletedEntityProvider { emptyList() } ) private fun tags(): Array = arrayOf( @@ -139,8 +154,7 @@ class PersistingScopeObserverTest( StoreScopeValue> { tags, _ -> setTags(tags) }, TAGS_FILENAME, DeleteScopeValue { setTags(emptyMap()) }, - DeletedEntityProvider { emptyMap() }, - null + DeletedEntityProvider { emptyMap() } ) private fun extras(): Array = arrayOf( @@ -152,8 +166,7 @@ class PersistingScopeObserverTest( StoreScopeValue> { extras, _ -> setExtras(extras) }, EXTRAS_FILENAME, DeleteScopeValue { setExtras(emptyMap()) }, - DeletedEntityProvider { emptyMap() }, - null + DeletedEntityProvider { emptyMap() } ) private fun request(): Array = arrayOf( @@ -168,8 +181,7 @@ class PersistingScopeObserverTest( StoreScopeValue { request, _ -> setRequest(request) }, REQUEST_FILENAME, DeleteScopeValue { setRequest(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun fingerprint(): Array = arrayOf( @@ -177,8 +189,7 @@ class PersistingScopeObserverTest( StoreScopeValue> { fingerprint, _ -> setFingerprint(fingerprint) }, FINGERPRINT_FILENAME, DeleteScopeValue { setFingerprint(emptyList()) }, - DeletedEntityProvider { emptyList() }, - null + DeletedEntityProvider { emptyList() } ) private fun level(): Array = arrayOf( @@ -186,8 +197,7 @@ class PersistingScopeObserverTest( StoreScopeValue { level, _ -> setLevel(level) }, LEVEL_FILENAME, DeleteScopeValue { setLevel(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun transaction(): Array = arrayOf( @@ -195,8 +205,7 @@ class PersistingScopeObserverTest( StoreScopeValue { transaction, _ -> setTransaction(transaction) }, TRANSACTION_FILENAME, DeleteScopeValue { setTransaction(null) }, - DeletedEntityProvider { null }, - null + DeletedEntityProvider { null } ) private fun trace(): Array = arrayOf( @@ -204,8 +213,7 @@ class PersistingScopeObserverTest( StoreScopeValue { trace, scope -> setTrace(trace, scope) }, TRACE_FILENAME, DeleteScopeValue { scope -> setTrace(null, scope) }, - DeletedEntityProvider { scope -> scope.propagationContext.toSpanContext() }, - null + DeletedEntityProvider { scope -> scope.propagationContext.toSpanContext() } ) private fun contexts(): Array = arrayOf( @@ -269,8 +277,7 @@ class PersistingScopeObserverTest( StoreScopeValue { contexts, _ -> setContexts(contexts) }, CONTEXTS_FILENAME, DeleteScopeValue { setContexts(Contexts()) }, - DeletedEntityProvider { Contexts() }, - null + DeletedEntityProvider { Contexts() } ) private fun replayId(): Array = arrayOf( @@ -278,8 +285,7 @@ class PersistingScopeObserverTest( StoreScopeValue { replayId, _ -> setReplayId(SentryId(replayId)) }, REPLAY_FILENAME, DeleteScopeValue { setReplayId(SentryId.EMPTY_ID) }, - DeletedEntityProvider { SentryId.EMPTY_ID.toString() }, - null + DeletedEntityProvider { SentryId.EMPTY_ID.toString() } ) @JvmStatic @@ -288,6 +294,7 @@ class PersistingScopeObserverTest( return listOf( user(), breadcrumbs(), + legacyBreadcrumbs(), tags(), extras(), request(), diff --git a/sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt b/sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt new file mode 100644 index 00000000000..1e5e0b03a0b --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/tape/CorruptQueueFileTest.kt @@ -0,0 +1,43 @@ +package io.sentry.cache.tape + +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.test.assertEquals + +class CorruptQueueFileTest { + + @get:Rule + val folder = TemporaryFolder() + private lateinit var file: File + + @Before + fun setUp() { + val parent = folder.root + file = File(parent, "queue-file") + } + + @Test + fun `does not fail to operate with a corrupt file`() { + val testFile = this::class.java.classLoader.getResource("corrupt_queue_file.txt")!! + Files.copy(Paths.get(testFile.toURI()), file.outputStream()) + + val queueFile = QueueFile.Builder(file).zero(true).build() + val iterator = queueFile.iterator() + while (iterator.hasNext()) { + iterator.next() + } + + queueFile.add("test".toByteArray()) + assertEquals(1, queueFile.size()) + + queueFile.peek() + + queueFile.remove() + assertEquals(0, queueFile.size()) + } +} diff --git a/sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt b/sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt new file mode 100644 index 00000000000..628db5d57bc --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/tape/ObjectQueueTest.kt @@ -0,0 +1,252 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/test/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape + +import io.sentry.cache.tape.ObjectQueue.Converter +import io.sentry.cache.tape.QueueFile.Builder +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException +import java.io.OutputStream +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail + +class ObjectQueueTest { + enum class QueueFactory { + FILE { + override fun create(queueFile: QueueFile, converter: Converter): ObjectQueue { + return ObjectQueue.create(queueFile, converter) + } + }; + + abstract fun create(queueFile: QueueFile, converter: Converter): ObjectQueue + } + + @get:Rule + val folder = TemporaryFolder() + private lateinit var queue: ObjectQueue + + @Before + fun setUp() { + val parent = folder.root + val file = File(parent, "object-queue") + val queueFile = Builder(file).build() + queue = QueueFactory.FILE.create(queueFile, StringConverter()) + + queue.add("one") + queue.add("two") + queue.add("three") + } + + @Test + fun size() { + assertEquals(queue.size(), 3) + } + + @Test + fun peek() { + assertEquals(queue.peek(), "one") + } + + @Test + fun peekMultiple() { + assertEquals(queue.peek(2), listOf("one", "two")) + } + + @Test + fun peekMaxCanExceedQueueDepth() { + assertEquals(queue.peek(6), listOf("one", "two", "three")) + } + + @Test + fun asList() { + assertEquals(queue.asList(), listOf("one", "two", "three")) + } + + @Test + fun remove() { + queue.remove() + + assertEquals(queue.asList(), listOf("two", "three")) + } + + @Test + fun removeMultiple() { + queue.remove(2) + + assertEquals(queue.asList(), listOf("three")) + } + + @Test + fun clear() { + queue.clear() + + assertEquals(queue.size(), 0) + } + + @Test + fun isEmpty() { + assertFalse(queue.isEmpty) + + queue.clear() + + assertTrue(queue.isEmpty) + } + + @Test + fun testIterator() { + val saw: MutableList = ArrayList() + for (pojo in queue) { + saw.add(pojo) + } + assertEquals(saw, listOf("one", "two", "three")) + } + + @Test + fun testIteratorNextThrowsWhenEmpty() { + queue.clear() + val iterator: Iterator = queue.iterator() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorNextThrowsWhenExhausted() { + val iterator: Iterator = queue.iterator() + iterator.next() + iterator.next() + iterator.next() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorRemove() { + val iterator = queue.iterator() + + iterator.next() + iterator.remove() + assertEquals(queue.asList(), listOf("two", "three")) + + iterator.next() + iterator.remove() + assertEquals(queue.asList(), listOf("three")) + } + + @Test + fun testIteratorRemoveDisallowsConcurrentModification() { + val iterator = queue.iterator() + iterator.next() + queue.remove() + + try { + iterator.remove() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorHasNextDisallowsConcurrentModification() { + val iterator: Iterator = queue.iterator() + iterator.next() + queue.remove() + + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorDisallowsConcurrentModificationWithClear() { + val iterator: Iterator = queue.iterator() + iterator.next() + queue.clear() + + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorOnlyRemovesFromHead() { + val iterator = queue.iterator() + iterator.next() + iterator.next() + + try { + iterator.remove() + fail() + } catch (ex: UnsupportedOperationException) { + assertEquals(ex.message, "Removal is only permitted from the head.") + } + } + + @Test + fun iteratorThrowsIOException() { + val parent = folder.root + val file = File(parent, "object-queue") + val queueFile = Builder(file).build() + val queue = ObjectQueue.create( + queueFile, + object : Converter { + override fun from(bytes: ByteArray): String { + throw IOException() + } + + override fun toStream(o: Any, bytes: OutputStream) { + } + } + ) + queue.add(Any()) + val iterator = queue.iterator() + try { + iterator.next() + fail() + } catch (ioe: Exception) { + assertTrue(ioe is IOException) + } + } + + internal class StringConverter : Converter { + override fun from(bytes: ByteArray): String { + return String(bytes, charset("UTF-8")) + } + + override fun toStream(s: String, os: OutputStream) { + os.write(s.toByteArray(charset("UTF-8"))) + } + } +} diff --git a/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt b/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt new file mode 100644 index 00000000000..8ece592c684 --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt @@ -0,0 +1,730 @@ +/* + * Adapted from: https://github.com/square/tape/tree/445cd3fd0a7b3ec48c9ea3e0e86663fe6d3735d8/tape/src/test/java/com/squareup/tape2 + * + * Copyright (C) 2010 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.cache.tape + +import io.sentry.cache.tape.QueueFile.Builder +import io.sentry.cache.tape.QueueFile.Element +import okio.BufferedSource +import okio.Okio +import org.junit.Assert +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.util.ArrayDeque +import java.util.Queue +import java.util.logging.Logger +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Tests for QueueFile. + * + * @author Bob Lee (bob@squareup.com) + */ +class QueueFileTest { + private val headerLength = 32 + + @get:Rule + val folder = TemporaryFolder() + private lateinit var file: File + + private fun newQueueFile(raf: RandomAccessFile): QueueFile { + return QueueFile(this.file, raf, true, -1) + } + + private fun newQueueFile(zero: Boolean = true, size: Int = -1): QueueFile { + return Builder(file).zero(zero).size(size).build() + } + + @Before + fun setUp() { + val parent = folder.root + file = File(parent, "queue-file") + } + + @Test + fun testAddOneElement() { + // This test ensures that we update 'first' correctly. + var queue = newQueueFile() + val expected = values[253] + queue.add(expected) + assertArrayEquals(queue.peek(), expected) + queue.close() + queue = newQueueFile() + assertArrayEquals(queue.peek(), expected) + } + + @Test + fun testClearErases() { + val queue = newQueueFile() + val expected = values[253] + queue.add(expected) + + // Confirm that the data was in the file before we cleared. + val data = ByteArray(expected!!.size) + queue.raf.seek(headerLength.toLong() + Element.HEADER_LENGTH) + queue.raf.readFully(data, 0, expected.size) + assertArrayEquals(data, expected) + + queue.clear() + + // Should have been erased. + queue.raf.seek(headerLength.toLong() + Element.HEADER_LENGTH) + queue.raf.readFully(data, 0, expected.size) + assertArrayEquals(data, ByteArray(expected.size)) + } + + @Test + fun testClearDoesNotCorrupt() { + var queue = newQueueFile() + val stuff = values[253] + queue.add(stuff) + queue.clear() + + queue = newQueueFile() + assertTrue(queue.isEmpty) + assertNull(queue.peek()) + + queue.add(values[25]) + assertArrayEquals(queue.peek(), values[25]) + } + + @Test + fun removeErasesEagerly() { + val queue = newQueueFile() + + val firstStuff = values[127] + queue.add(firstStuff) + + val secondStuff = values[253] + queue.add(secondStuff) + + // Confirm that first stuff was in the file before we remove. + val data = ByteArray(firstStuff!!.size) + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, firstStuff.size) + assertArrayEquals(data, firstStuff) + + queue.remove() + + // Next record is intact + assertArrayEquals(queue.peek(), secondStuff) + + // First should have been erased. + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, firstStuff.size) + assertArrayEquals(data, ByteArray(firstStuff.size)) + } + + @Test + fun testZeroSizeInHeaderThrows() { + val emptyFile = RandomAccessFile(file, "rwd") + emptyFile.setLength(QueueFile.INITIAL_LENGTH.toLong()) + emptyFile.channel.force(true) + emptyFile.close() + + try { + newQueueFile() + fail("Should have thrown about bad header length") + } catch (ex: IOException) { + assertEquals(ex.message, "File is corrupt; length stored in header (0) is invalid.") + } + } + + @Test + fun testSizeLessThanHeaderThrows() { + val emptyFile = RandomAccessFile(file, "rwd") + emptyFile.setLength(QueueFile.INITIAL_LENGTH.toLong()) + emptyFile.writeInt(-0x7fffffff) + emptyFile.writeLong((headerLength - 1).toLong()) + emptyFile.channel.force(true) + emptyFile.close() + + try { + newQueueFile() + fail() + } catch (ex: IOException) { + assertEquals(ex.message, "File is corrupt; length stored in header (31) is invalid.") + } + } + + @Test + fun testNegativeSizeInHeaderThrows() { + val emptyFile = RandomAccessFile(file, "rwd") + emptyFile.seek(0) + emptyFile.writeInt(-2147483648) + emptyFile.setLength(QueueFile.INITIAL_LENGTH.toLong()) + emptyFile.channel.force(true) + emptyFile.close() + + try { + newQueueFile() + fail("Should have thrown about bad header length") + } catch (ex: IOException) { + assertEquals(ex.message, "File is corrupt; length stored in header (0) is invalid.") + } + } + + @Test + fun removeMultipleDoesNotCorrupt() { + var queue = newQueueFile() + for (i in 0..9) { + queue.add(values[i]) + } + + queue.remove(1) + assertEquals(queue.size(), 9) + assertArrayEquals(queue.peek(), values[1]) + + queue.remove(3) + queue = newQueueFile() + assertEquals(queue.size(), 6) + assertArrayEquals(queue.peek(), values[4]) + + queue.remove(6) + assertTrue(queue.isEmpty) + assertNull(queue.peek()) + } + + @Test + fun removeDoesNotCorrupt() { + var queue = newQueueFile() + + queue.add(values[127]) + val secondStuff = values[253] + queue.add(secondStuff) + queue.remove() + + queue = newQueueFile() + assertArrayEquals(queue.peek(), secondStuff) + } + + @Test + fun removeFromEmptyFileThrows() { + val queue = newQueueFile() + + try { + queue.remove() + fail("Should have thrown about removing from empty file.") + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun removeZeroFromEmptyFileDoesNothing() { + val queue = newQueueFile() + queue.remove(0) + assertTrue(queue.isEmpty) + } + + @Test + fun removeNegativeNumberOfElementsThrows() { + val queue = newQueueFile() + queue.add(values[127]) + + try { + queue.remove(-1) + fail("Should have thrown about removing negative number of elements.") + } catch (ex: IllegalArgumentException) { + assertEquals(ex.message, "Cannot remove negative (-1) number of elements.") + } + } + + @Test + fun removeZeroElementsDoesNothing() { + val queue = newQueueFile() + queue.add(values[127]) + + queue.remove(0) + assertEquals(queue.size(), 1) + } + + @Test + fun removeBeyondQueueSizeElementsThrows() { + val queue = newQueueFile() + queue.add(values[127]) + + try { + queue.remove(10) + fail("Should have thrown about removing too many elements.") + } catch (ex: IllegalArgumentException) { + assertEquals(ex.message, "Cannot remove more elements (10) than present in queue (1).") + } + } + + @Test + fun removingBigDamnBlocksErasesEffectively() { + val bigBoy = ByteArray(7000) + var i = 0 + while (i < 7000) { + System.arraycopy(values[100], 0, bigBoy, i, values[100]!!.size) + i += 100 + } + + val queue = newQueueFile() + + queue.add(bigBoy) + val secondStuff = values[123] + queue.add(secondStuff) + + // Confirm that bigBoy was in the file before we remove. + val data = ByteArray(bigBoy.size) + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, bigBoy.size) + assertArrayEquals(data, bigBoy) + + queue.remove() + + // Next record is intact + assertArrayEquals(queue.peek(), secondStuff) + + // First should have been erased. + queue.raf.seek((headerLength + Element.HEADER_LENGTH).toLong()) + queue.raf.readFully(data, 0, bigBoy.size) + assertArrayEquals(data, ByteArray(bigBoy.size)) + } + + @Test + fun testAddAndRemoveElements() { + val start = System.nanoTime() + + val expected: Queue = ArrayDeque() + + for (round in 0..4) { + val queue = newQueueFile() + for (i in 0 until N) { + queue.add(values[i]) + expected.add(values[i]) + } + + // Leave N elements in round N, 15 total for 5 rounds. Removing all the + // elements would be like starting with an empty queue. + for (i in 0 until N - round - 1) { + assertArrayEquals(queue.peek(), expected.remove()) + queue.remove() + } + queue.close() + } + + // Remove and validate remaining 15 elements. + val queue = newQueueFile() + assertEquals(queue.size(), 15) + assertEquals(queue.size(), expected.size) + while (!expected.isEmpty()) { + assertArrayEquals(queue.peek(), expected.remove()) + queue.remove() + } + queue.close() + + // length() returns 0, but I checked the size w/ 'ls', and it is correct. + // assertEquals(65536, file.length()); + logger.info("Ran in " + ((System.nanoTime() - start) / 1000000) + "ms.") + } + + @Test + fun testFailedAdd() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + + try { + queueFile.add(values[252]) + Assert.fail() + } catch (e: IOException) { /* expected */ + } + + braf.rejectCommit = false + + // Allow a subsequent add to succeed. + queueFile.add(values[251]) + + queueFile.close() + + queueFile = newQueueFile() + assertEquals(queueFile.size(), 2) + assertArrayEquals(queueFile.peek(), values[253]) + queueFile.remove() + assertArrayEquals(queueFile.peek(), values[251]) + } + + @Test + fun testFailedRemoval() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + + try { + queueFile.remove() + fail() + } catch (e: IOException) { /* expected */ + } + + queueFile.close() + + queueFile = newQueueFile() + assertEquals(queueFile.size(), 1) + assertArrayEquals(queueFile.peek(), values[253]) + + queueFile.add(values[99]) + queueFile.remove() + assertArrayEquals(queueFile.peek(), values[99]) + } + + @Test + fun testFailedExpansion() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + + try { + // This should trigger an expansion which should fail. + queueFile.add(ByteArray(8000)) + fail() + } catch (e: IOException) { /* expected */ + } + + queueFile.close() + + queueFile = newQueueFile() + assertEquals(queueFile.size(), 1) + assertArrayEquals(queueFile.peek(), values[253]) + assertEquals(queueFile.fileLength, 4096) + + queueFile.add(values[99]) + queueFile.remove() + assertArrayEquals(queueFile.peek(), values[99]) + } + + @Test + fun removingElementZeroesData() { + val queueFile = newQueueFile(true) + queueFile.add(values[4]) + queueFile.remove() + queueFile.close() + + val source: BufferedSource = Okio.buffer(Okio.source(file)) + source.skip(headerLength.toLong()) + source.skip(Element.HEADER_LENGTH.toLong()) + assertEquals(source.readByteString(4).hex(), "00000000") + } + + @Test + fun removingElementDoesNotZeroData() { + val queueFile = newQueueFile(false) + queueFile.add(values[4]) + queueFile.remove() + queueFile.close() + + val source: BufferedSource = Okio.buffer(Okio.source(file)) + source.skip(headerLength.toLong()) + source.skip(Element.HEADER_LENGTH.toLong()) + assertEquals(source.readByteString(4).hex(), "04030201") + + source.close() + } + + /** + * Exercise a bug where opening a queue whose first or last element's header + * was non contiguous throws an [java.io.EOFException]. + */ + @Test + fun testReadHeadersFromNonContiguousQueueWorks() { + val queueFile = newQueueFile() + + // Fill the queue up to `length - 2` (i.e. remainingBytes() == 2). + for (i in 0..14) { + queueFile.add(values[N - 1]) + } + queueFile.add(values[219]) + + // Remove first item so we have room to add another one without growing the file. + queueFile.remove() + + // Add any element element and close the queue. + queueFile.add(values[6]) + val queueSize = queueFile.size() + queueFile.close() + + // File should not be corrupted. + val queueFile2 = newQueueFile() + assertEquals(queueFile2.size(), queueSize) + } + + @Test + fun testIterator() { + val data = values[10] + + for (i in 0..9) { + val queueFile = newQueueFile() + for (j in 0 until i) { + queueFile.add(data) + } + + var saw = 0 + for (element in queueFile) { + assertArrayEquals(element, data) + saw++ + } + assertEquals(saw, i) + queueFile.close() + file!!.delete() + } + } + + @Test + fun testIteratorNextThrowsWhenEmpty() { + val queueFile = newQueueFile() + + val iterator: Iterator = queueFile.iterator() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorNextThrowsWhenExhausted() { + val queueFile = newQueueFile() + queueFile.add(values[0]) + + val iterator: Iterator = queueFile.iterator() + iterator.next() + + try { + iterator.next() + fail() + } catch (ignored: NoSuchElementException) { + } + } + + @Test + fun testIteratorRemove() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator = queueFile.iterator() + while (iterator.hasNext()) { + iterator.next() + iterator.remove() + } + + assertTrue(queueFile.isEmpty) + } + + @Test + fun testIteratorRemoveDisallowsConcurrentModification() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator = queueFile.iterator() + iterator.next() + queueFile.remove() + try { + iterator.remove() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorHasNextDisallowsConcurrentModification() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator: Iterator = queueFile.iterator() + iterator.next() + queueFile.remove() + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorDisallowsConcurrentModificationWithClear() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator: Iterator = queueFile.iterator() + iterator.next() + queueFile.clear() + try { + iterator.hasNext() + fail() + } catch (ignored: ConcurrentModificationException) { + } + } + + @Test + fun testIteratorOnlyRemovesFromHead() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + val iterator = queueFile.iterator() + iterator.next() + iterator.next() + + try { + iterator.remove() + fail() + } catch (ex: UnsupportedOperationException) { + assertEquals(ex.message, "Removal is only permitted from the head.") + } + } + + @Test + fun iteratorThrowsIOException() { + var queueFile = newQueueFile() + queueFile.add(values[253]) + queueFile.close() + + class BrokenRandomAccessFile(file: File?, mode: String?) : RandomAccessFile(file, mode) { + var fail: Boolean = false + + override fun write(b: ByteArray, off: Int, len: Int) { + if (fail) { + throw IOException() + } + super.write(b, off, len) + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (fail) { + throw IOException() + } + return super.read(b, off, len) + } + } + + val braf = BrokenRandomAccessFile(file, "rwd") + queueFile = newQueueFile(braf) + val iterator = queueFile.iterator() + + braf.fail = true + try { + iterator.next() + fail() + } catch (ioe: Exception) { + assertTrue(ioe is IOException) + } + + braf.fail = false + iterator.next() + + braf.fail = true + try { + iterator.remove() + fail() + } catch (ioe: Exception) { + assertTrue(ioe is IOException) + } + } + + @Test + fun queueToString() { + val queueFile = newQueueFile() + for (i in 0..14) { + queueFile.add(values[i]) + } + + assertTrue( + queueFile.toString().contains( + "zero=true, length=4096," + + " size=15," + + " first=Element[position=32, length=0], last=Element[position=179, length=14]}" + ) + ) + } + + @Test + fun `wraps elements around when size is specified`() { + val queue = newQueueFile(size = 2) + + for (i in 0 until 3) { + queue.add(values[i]) + } + + // Confirm that first element now is values[1] in the file after wrapping + assertArrayEquals(queue.peek(), values[1]) + queue.remove() + + // Confirm that first element now is values[2] in the file after wrapping + assertArrayEquals(queue.peek(), values[2]) + } + + /** + * A RandomAccessFile that can break when you go to write the COMMITTED + * status. + */ + internal class BrokenRandomAccessFile(file: File?, mode: String?) : RandomAccessFile(file, mode) { + var rejectCommit: Boolean = true + override fun write(b: ByteArray, off: Int, len: Int) { + if (rejectCommit && filePointer == 0L) { + throw IOException("No commit for you!") + } + super.write(b, off, len) + } + } + + companion object { + private val logger: Logger = Logger.getLogger( + QueueFileTest::class.java.name + ) + + /** + * Takes up 33401 bytes in the queue (N*(N+1)/2+4*N). Picked 254 instead of 255 so that the number + * of bytes isn't a multiple of 4. + */ + private const val N = 254 + private val values = Array(N) { i -> + val value = ByteArray(i) + // Example: values[3] = { 3, 2, 1 } + for (ii in 0 until i) value[ii] = (i - ii).toByte() + value + } + } +} diff --git a/sentry/src/test/resources/corrupt_queue_file.txt b/sentry/src/test/resources/corrupt_queue_file.txt new file mode 100644 index 0000000000000000000000000000000000000000..2eca21fb255ad4e651f0a767d5c2c83dc208bb77 GIT binary patch literal 4100 zcmeH@J#WJx5Qa&|O#KNKb8Q(yWt=Hnm9?tW`D`C4l1&p#yi}F)(~Cc%CQ|n{!k#CdW&-irv^oG9c zZy&G&+r|Vxz_2@=Pp(0q*-t*Xa6lI-Z^Kb5Q6vIHfCvx)B0vO)01+SpM1Tko0V42E1bzTiU{pE) literal 0 HcmV?d00001 From cd913d1ca6938e813db808aa3be8e8fee777b438 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 12:13:18 +0000 Subject: [PATCH 037/914] Bump JamesIves/github-pages-deploy-action from 4.7.2 to 4.7.3 (#4194) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.7.2 to 4.7.3. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/15de0f09300eea763baee31dff6c6184995c5f6a...6c2d9db40f9296374acc17b90404b6e8864128c8) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/generate-javadocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 62c8c301817..80b776d1607 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -28,7 +28,7 @@ jobs: run: | ./gradlew aggregateJavadocs - name: Deploy - uses: JamesIves/github-pages-deploy-action@15de0f09300eea763baee31dff6c6184995c5f6a # pin@4.7.2 + uses: JamesIves/github-pages-deploy-action@6c2d9db40f9296374acc17b90404b6e8864128c8 # pin@4.7.3 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages From d364b9048035e35c237c021e7878f0c3b8d138f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 13:20:37 +0100 Subject: [PATCH 038/914] Bump github/codeql-action from 3.28.9 to 3.28.11 (#4242) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.9 to 3.28.11. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0...6bb031afdd8eb862ea3fc1848194185e076637e5) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 39dfbdb0907..05e08087d1e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -40,7 +40,7 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # pin@v2 + uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # pin@v2 with: languages: 'java' @@ -49,4 +49,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # pin@v2 + uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # pin@v2 From 4a6043e789f042c2964290e5166a0829bf6461c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 12:23:57 +0000 Subject: [PATCH 039/914] Bump actions/create-github-app-token from 1.11.5 to 1.11.6 (#4220) Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 1.11.5 to 1.11.6. - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Commits](https://github.com/actions/create-github-app-token/compare/0d564482f06ca65fa9e77e2510873638c82206f2...21cfef2b496dd8ef5b904c159339626a10ad380e) --- updated-dependencies: - dependency-name: actions/create-github-app-token dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af873abff7b..d7388c370b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@0d564482f06ca65fa9e77e2510873638c82206f2 # v1.11.5 + uses: actions/create-github-app-token@21cfef2b496dd8ef5b904c159339626a10ad380e # v1.11.6 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From 01e384512efa86b850ecf381a0d6593873c4c464 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 13:28:12 +0100 Subject: [PATCH 040/914] Bump codecov/codecov-action from 5.3.1 to 5.4.0 (#4219) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.3.1 to 5.4.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3...0565863a31f2c772f9f0395002a31e3f06189574) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3dea6dd3b0..bf0082700de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,7 +46,7 @@ jobs: run: make preMerge - name: Upload coverage to Codecov - uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # pin@v4 + uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # pin@v4 with: name: sentry-java fail_ci_if_error: false From 11bd630d40be8e7c440db951d07334e6af636014 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 13:32:46 +0100 Subject: [PATCH 041/914] Bump gradle/actions (#4243) Bumps [gradle/actions](https://github.com/gradle/actions) from aa23778d2dc6f6556fcc7164e99babbd8c3134e4 to 4504a95ca57b383b150e5f64cece035031420365. - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/aa23778d2dc6f6556fcc7164e99babbd8c3134e4...4504a95ca57b383b150e5f64cece035031420365) --- updated-dependencies: - dependency-name: gradle/actions dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-ui-critical.yml | 2 +- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 99dd460a562..32982b72491 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -39,7 +39,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf0082700de..5d3d03e1c76 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 05e08087d1e..d806bf47214 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -34,7 +34,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 6b793131f37..88ffbfb64cc 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 80b776d1607..fe5f0dad23c 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index de40849718c..6160282ad56 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -38,7 +38,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -89,7 +89,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 05cd811f5a0..c7a90838bbd 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -36,7 +36,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index f62408e7f40..0bb09140398 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -33,7 +33,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 975ab2ff1a2..164b7380a18 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 93d007b15b9..65cbd5fb4cf 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -57,7 +57,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@aa23778d2dc6f6556fcc7164e99babbd8c3134e4 # pin@v3 + uses: gradle/actions/setup-gradle@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 with: gradle-home-cache-cleanup: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} From 1b7c68d2f75378059eb13ab0010b73c16e2cfe49 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 14:40:31 +0100 Subject: [PATCH 042/914] chore(deps): update Gradle to v8.13.0 (#4209) * chore: update scripts/update-gradle.sh to v8.13.0 * Do not use identityPath as it doesn't exist anymore --------- Co-authored-by: GitHub Co-authored-by: Roman Zavarnitsyn --- CHANGELOG.md | 6 ++++++ gradle/wrapper/gradle-wrapper.jar | Bin 43504 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 3 +-- .../build.gradle.kts | 4 +--- .../build.gradle.kts | 4 +--- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbf069a858c..9dbc093d0f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) +### Dependencies + +- Bump Gradle from v8.12.1 to v8.13.0 ([#4209](https://github.com/getsentry/sentry-java/pull/4209)) + - [changelog](https://github.com/gradle/gradle/blob/master/CHANGELOG.md#v8130) + - [diff](https://github.com/gradle/gradle/compare/v8.12.1...v8.13.0) + ## 8.4.0 ### Fixes diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 2c3521197d7c4586c843d1d3e9090525f1898cde..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch delta 3990 zcmV;H4{7l5(*nQL0Kr1kzC=_KMxQY0|W5(lc#i zH*M1^P4B}|{x<+fkObwl)u#`$GxKKV&3pg*-y6R6txw)0qU|Clf9Uds3x{_-**c=7 z&*)~RHPM>Rw#Hi1R({;bX|7?J@w}DMF>dQQU2}9yj%iLjJ*KD6IEB2^n#gK7M~}6R zkH+)bc--JU^pV~7W=3{E*4|ZFpDpBa7;wh4_%;?XM-5ZgZNnVJ=vm!%a2CdQb?oTa z70>8rTb~M$5Tp!Se+4_OKWOB1LF+7gv~$$fGC95ToUM(I>vrd$>9|@h=O?eARj0MH zT4zo(M>`LWoYvE>pXvqG=d96D-4?VySz~=tPVNyD$XMshoTX(1ZLB5OU!I2OI{kb) zS8$B8Qm>wLT6diNnyJZC?yp{Kn67S{TCOt-!OonOK7$K)e-13U9GlnQXPAb&SJ0#3 z+vs~+4Qovv(%i8g$I#FCpCG^C4DdyQw3phJ(f#y*pvNDQCRZ~MvW<}fUs~PL=4??j zmhPyg<*I4RbTz|NHFE-DC7lf2=}-sGkE5e!RM%3ohM7_I^IF=?O{m*uUPH(V?gqyc(Rp?-Qu(3bBIL4Fz(v?=_Sh?LbK{nqZMD>#9D_hNhaV$0ef3@9V90|0u#|PUNTO>$F=qRhg1duaE z0`v~X3G{8RVT@kOa-pU+z8{JWyP6GF*u2e8eKr7a2t1fuqQy)@d|Qn(%YLZ62TWtoX@$nL}9?atE#Yw`rd(>cr0gY;dT9~^oL;u)zgHUvxc2I*b&ZkGM-iq=&(?kyO(3}=P! zRp=rErEyMT5UE9GjPHZ#T<`cnD)jyIL!8P{H@IU#`e8cAG5jMK zVyKw7--dAC;?-qEu*rMr$5@y535qZ6p(R#+fLA_)G~!wnT~~)|s`}&fA(s6xXN`9j zP#Fd3GBa#HeS{5&8p?%DKUyN^X9cYUc6vq}D_3xJ&d@=6j(6BZKPl?!k1?!`f3z&a zR4ZF60Mx7oBxLSxGuzA*Dy5n-d2K=+)6VMZh_0KetK|{e;E{8NJJ!)=_E~1uu=A=r zrn&gh)h*SFhsQJo!f+wKMIE;-EOaMSMB@aXRU(UcnJhZW^B^mgs|M9@5WF@s6B0p& zm#CTz)yiQCgURE{%hjxHcJ6G&>G9i`7MyftL!QQd5 z@RflRs?7)99?X`kHNt>W3l7YqscBpi*R2+fsgABor>KVOu(i(`03aytf2UA!&SC9v z!E}whj#^9~=XHMinFZ;6UOJjo=mmNaWkv~nC=qH9$s-8roGeyaW-E~SzZ3Gg>j zZ8}<320rg4=$`M0nxN!w(PtHUjeeU?MvYgWKZ6kkzABK;vMN0|U;X9abJleJA(xy<}5h5P(5 z{RzAFPvMnX2m0yH0Jn2Uo-p`daE|(O`YQiC#jB8;6bVIUf?SY(k$#C0`d6qT`>Xe0+0}Oj0=F&*D;PVe=Z<=0AGI<6$gYLwa#r` zm449x*fU;_+J>Mz!wa;T-wldoBB%&OEMJgtm#oaI60TSYCy7;+$5?q!zi5K`u66Wq zvg)Fx$s`V3Em{=OEY{3lmh_7|08ykS&U9w!kp@Ctuzqe1JFOGz6%i5}Kmm9>^=gih z?kRxqLA<3@e=}G4R_?phW{4DVr?`tPfyZSN@R=^;P;?!2bh~F1I|fB7P=V=9a6XU5 z<#0f>RS0O&rhc&nTRFOW7&QhevP0#>j0eq<1@D5yAlgMl5n&O9X|Vq}%RX}iNyRFF z7sX&u#6?E~bm~N|z&YikXC=I0E*8Z$v7PtWfjy)$e_Ez25fnR1Q=q1`;U!~U>|&YS zaOS8y!^ORmr2L4ik!IYR8@Dcx8MTC=(b4P6iE5CnrbI~7j7DmM8em$!da&D!6Xu)!vKPdLG z9f#)se|6=5yOCe)N6xDhPI!m81*dNe7u985zi%IVfOfJh69+#ag4ELzGne?o`eA`42K4T)h3S+s)5IT97%O>du- z0U54L8m4}rkRQ?QBfJ%DLssy^+a7Ajw;0&`NOTY4o;0-ivm9 zBz1C%nr_hQ)X)^QM6T1?=yeLkuG9Lf50(eH}`tFye;01&(p?8i+6h};VV-2B~qdxeC#=X z(JLlzy&fHkyi9Ksbcs~&r^%lh^2COldLz^H@X!s~mr9Dr6z!j+4?zkD@Ls7F8(t(f z9`U?P$Lmn*Y{K}aR4N&1N=?xtQ1%jqf1~pJyQ4SgBrEtR`j4lQuh7cqP49Em5cO=I zB(He2`iPN5M=Y0}h(IU$37ANTGx&|b-u1BYA*#dE(L-lptoOpo&th~E)_)y-`6kSH z3vvyVrcBwW^_XYReJ=JYd9OBQrzv;f2AQdZH#$Y{Y+Oa33M70XFI((fs;mB4e`<<{ ze4dv2B0V_?Ytsi>>g%qs*}oDGd5d(RNZ*6?7qNbdp7wP4T72=F&r?Ud#kZr8Ze5tB z_oNb7{G+(o2ajL$!69FW@jjPQ2a5C)m!MKKRirC$_VYIuVQCpf9rIms0GRDf)8AH${I`q^~5rjot@#3$2#zT2f`(N^P7Z;6(@EK$q*Jgif00I6*^ZGV+XB5uw*1R-@23yTw&WKD{s1;HTL;dO)%5i#`dc6b7;5@^{KU%N|A-$zsYw4)7LA{3`Zp>1 z-?K9_IE&z)dayUM)wd8K^29m-l$lFhi$zj0l!u~4;VGR6Y!?MAfBC^?QD53hy6VdD z@eUZIui}~L%#SmajaRq1J|#> z4m=o$vZ*34=ZWK2!QMNEcp2Lbc5N1q!lEDq(bz0b;WI9;e>l=CG9^n#ro`w>_0F$Q zfZ={2QyTkfByC&gy;x!r*NyXXbk=a%~~(#K?< zTke0HuF5{Q+~?@!KDXR|g+43$+;ab`^flS%miup_0OUTm=nIc%d5nLP)i308PIjl_YMF6cpQ__6&$n6it8K- z8PIjl_YMF6cpQ_!r)L8IivW`WdK8mBs6PXdjR2DYdK8nCs73=4j{uVadK8oNjwX|E wpAeHLsTu^*Y>Trk?aBtSQ(D-o$(D8Px^?ZI-PUB? z*1fv!{YdHme3Fc8%cR@*@zc5A_nq&2=R47Hp@$-JF4Fz*;SLw5}K^y>s-s;V!}b2i=5=M- zComP?ju>8Fe@=H@rlwe1l`J*6BTTo`9b$zjQ@HxrAhp0D#u?M~TxGC_!?ccCHCjt| zF*PgJf@kJB`|Ml}cmsyrAjO#Kjr^E5p29w+#>$C`Q|54BoDv$fQ9D?3n32P9LPMIzu?LjNqggOH=1@T{9bMn*u8(GI z!;MLTtFPHal^S>VcJdiYqX0VU|Rn@A}C1xOlxCribxes0~+n2 z6qDaIA2$?e`opx3_KW!rAgbpzU)gFdjAKXh|5w``#F0R|c)Y)Du0_Ihhz^S?k^pk% zP>9|pIDx)xHH^_~+aA=^$M!<8K~Hy(71nJGf6`HnjtS=4X4=Hk^O71oNia2V{HUCC zoN3RSBS?mZCLw;l4W4a+D8qc)XJS`pUJ5X-f^1ytxwr`@si$lAE?{4G|o; zO0l>`rr?;~c;{ZEFJ!!3=7=FdGJ?Q^xfNQh4A?i;IJ4}B+A?4olTK(fN++3CRBP97 ze~lG9h%oegkn)lpW-4F8o2`*WW0mZHwHez`ko@>U1_;EC_6ig|Drn@=DMV9YEUSCa zIf$kHei3(u#zm9I!Jf(4t`Vm1lltJ&lVHy(eIXE8sy9sUpmz%I_gA#8x^Zv8%w?r2 z{GdkX1SkzRIr>prRK@rqn9j2wG|rUvf6PJbbin=yy-TAXrguvzN8jL$hUrIXzr^s5 zVM?H4;eM-QeRFr06@ifV(ocvk?_)~N@1c2ien56UjWXid6W%6ievIh)>dk|rIs##^kY67ib8Kw%#-oVFaXG7$ERyA9(NSJUvWiOA5H(!{uOpcW zg&-?iqPhds%3%tFspHDqqr;A!e@B#iPQjHd=c>N1LoOEGRehVoPOdxJ>b6>yc#o#+ zl8s8!(|NMeqjsy@0x{8^j0d00SqRZjp{Kj)&4UHYGxG+z9b-)72I*&J70?+8e?p_@ z=>-(>l6z5vYlP~<2%DU02b!mA{7mS)NS_eLe=t)sm&+Pmk?asOEKlkPQ)EUvvfC=;4M&*|I!w}(@V_)eUKLA_t^%`o z0PM9LV|UKTLnk|?M3u!|f2S0?UqZsEIH9*NJS-8lzu;A6-rr-ot=dg9SASoluZUkFH$7X; zP=?kYX!K?JL-b~<#7wU;b;eS)O;@?h%sPPk{4xEBxb{!sm0AY|f9cNvx6>$3F!*0c z75H=dy8JvTyO8}g1w{$9T$p~5en}AeSLoCF>_RT9YPMpChUjl310o*$QocjbH& zbnwg#gssR#jDVN{uEi3n(PZ%PFZ|6J2 z5_rBf0-u>e4sFe0*Km49ATi7>Kn0f9!uc|rRMR1Dtt6m1LW8^>qFlo}h$@br=Rmpi z;mI&>OF64Be{dVeHI8utrh)v^wsZ0jii%x8UgZ8TC%K~@I(4E};GFW&(;WVov}3%H zH;IhRkfD^(vt^DjZz(MyHLZxv8}qzPc(%itBkBwf_fC~sDBgh<3XAv5cxxfF3<2U! z03Xe&z`is!JDHbe;mNmfkH+_LFE*I2^mdL@7(@9DfAcP6O04V-ko;Rpgp<%Cj5r8Z zd0`sXoIjV$j)--;jA6Zy^D5&5v$o^>e%>Q?9GLm{i~p^lAn!%ZtF$I~>39XVZxk0b zROh^Bk9cE0AJBLozZIEmy7xG(yHWGztvfnr0(2ro1%>zsGMS^EMu+S$r=_;9 zWwZkgf7Q7`H9sLf2Go^Xy6&h~a&%s2_T@_Csf19MntF$aVFiFkvE3_hUg(B@&Xw@YJ zpL$wNYf78=0c@!QU6_a$>CPiXT7QAGDM}7Z(0z#_ZA=fmLUj{2z7@Ypo71UDy8GHr z-&TLKf6a5WCf@Adle3VglBt4>Z>;xF}}-S~B7<(%B;Y z0QR55{z-buw>8ilNM3u6I+D$S%?)(p>=eBx-HpvZj{7c*_?K=d()*7q?93us}1dq%FAFYLsW8ZTQ_XZLh`P2*6(NgS}qGcfGXVWpwsp#Rs}IuKbk*`2}&) zI^Vsk6S&Q4@oYS?dJ`NwMVBs6f57+RxdqVub#PvMu?$=^OJy5xEl0<5SLsSRy%%a0 zi}Y#1-F3m;Ieh#Y12UgW?-R)|eX>ZuF-2cc!1>~NS|XSF-6In>zBoZg+ml!6%fk7U zw0LHcz8VQk(jOJ+Yu)|^|15ufl$KQd_1eUZZzj`aC%umU6F1&D5XVWce_wAe(qCSZ zpX-QF4e{EmEVN9~6%bR5U*UT{eMHfcUo`jw*u?4r2s_$`}U{?NjvEm(u&<>B|%mq$Q3weshxk z76<``8vh{+nX`@9CB6IE&z)I%IFjR^LH{s1p|eppv=x za(g_jLU|xjWMAn-V7th$f({|LG8zzIE0g?cyW;%Dmtv%C+0@xVxPE^ zyZzi9P%JAD6ynwHptuzP`Kox7*9h7XSMonCalv;Md0i9Vb-c*!f0ubfk?&T&T}AHh z4m8Bz{JllKcdNg?D^%a5MFQ;#1z|*}H^qHLzW)L}wp?2tY7RejtSh8<;Zw)QGJYUm z|MbTxyj*McKlStlT9I5XlSWtQGN&-LTr2XyNU+`490rg?LYLMRnz-@oKqT1hpCGqP zyRXt4=_Woj$%n5ee<3zhLF>5>`?m9a#xQH+Jk_+|RM8Vi;2*XbK- zEL6sCpaGPzP>k8f4Kh|##_imt#zJMB;ir|JrMPGW`rityK1vHXMLy18%qmMQAm4WZ zP)i30KR&5vs15)C+8dM66&$k~i|ZT;KR&5vs15)C+8dJ(sAmGPijyIz6_bsqKLSFH zlOd=TljEpH0>h4zA*dCTK&emy#FCRCs1=i^sZ9bFmXjf<6_X39E(XY)00000#N437 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e18bc253b85..37f853b1c84 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f5feea6d6b1..f3b75f3b0d4 100755 --- a/gradlew +++ b/gradlew @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index bbea7d9cc52..bac6c483f9d 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -84,9 +84,7 @@ tasks.register("bootRunWithAgent").configure { classpath = mainBootRunTask.classpath val versionName = project.properties["versionName"] as String - val agentProjectId = projects.sentryOpentelemetry.sentryOpentelemetryAgent.identityPath.toString() - val agentProjectPath = project(agentProjectId).projectDir.absolutePath - val agentJarPath = "$agentProjectPath/build/libs/sentry-opentelemetry-agent-$versionName.jar" + val agentJarPath = "$rootDir/sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/sentry-opentelemetry-agent-$versionName.jar" val dsn = System.getenv("SENTRY_DSN") ?: "https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563" val tracesSampleRate = System.getenv("SENTRY_TRACES_SAMPLE_RATE") ?: "1" diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index a98538eaab9..1180d155375 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -85,9 +85,7 @@ tasks.register("bootRunWithAgent").configure { classpath = mainBootRunTask.classpath val versionName = project.properties["versionName"] as String - val agentProjectId = projects.sentryOpentelemetry.sentryOpentelemetryAgent.identityPath.toString() - val agentProjectPath = project(agentProjectId).projectDir.absolutePath - val agentJarPath = "$agentProjectPath/build/libs/sentry-opentelemetry-agent-$versionName.jar" + val agentJarPath = "$rootDir/sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/sentry-opentelemetry-agent-$versionName.jar" val dsn = System.getenv("SENTRY_DSN") ?: "https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563" val tracesSampleRate = System.getenv("SENTRY_TRACES_SAMPLE_RATE") ?: "1" From 0e7364a1fa64c4393972c9fc07a1a7b3e4cfc713 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 17 Mar 2025 08:47:33 +0100 Subject: [PATCH 043/914] Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running (#4250) * Fix Ensure app start type is set, even when ActivityLifecycleIntegration is not running (#4216) * Ensure app start type is set, even when ActivityLifecycleIntegration is not activated * Update Changelog * Add proper tests * Add code comments * Unify handling * Move all app start handling to AppStartMetrics * Make tests happy * Fix flaky RateLimiter test (#4100) * changed RateLimiterTest `close cancels the timer` to use reflection * Update sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java Co-authored-by: Stefano * Address PR feedback * Fix post-merge conflict * Format code * Address PR feedback * Address PR feedback * Update sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java --------- Co-authored-by: Stefano Co-authored-by: Sentry Github Bot * Fix properly reset application/content-provider timespans * Update Changelog * Fix tests --------- Co-authored-by: Stefano Co-authored-by: Sentry Github Bot --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 6 +- .../core/ActivityLifecycleIntegration.java | 29 +-- .../io/sentry/android/core/SentryAndroid.java | 2 +- .../core/SentryPerformanceProvider.java | 9 +- .../core/performance/AppStartMetrics.java | 166 ++++++++----- .../core/ActivityLifecycleIntegrationTest.kt | 188 +------------- .../PerformanceAndroidEventProcessorTest.kt | 3 +- .../core/SentryPerformanceProviderTest.kt | 22 +- .../core/performance/AppStartMetricsTest.kt | 230 ++++++++++++++---- 10 files changed, 324 insertions(+), 332 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbc093d0f4..abf287298b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) +- Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4250](https://github.com/getsentry/sentry-java/pull/4250)) ### Dependencies diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 85197f80380..6e42257b576 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -471,15 +471,15 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun isAppLaunchedInForeground ()Z - public fun isColdStartValid ()Z public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V + public fun onActivityDestroyed (Landroid/app/Activity;)V + public fun onActivityStarted (Landroid/app/Activity;)V public fun onAppStartSpansSent ()V public static fun onApplicationCreate (Landroid/app/Application;)V public static fun onApplicationPostCreate (Landroid/app/Application;)V public static fun onContentProviderCreate (Landroid/content/ContentProvider;)V public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V - public fun registerApplicationForegroundCheck (Landroid/app/Application;)V - public fun restartAppStart (J)V + public fun registerLifecycleCallbacks (Landroid/app/Application;)V public fun setAppLaunchedInForeground (Z)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 0bdfee71fd4..34d20cf455c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -9,7 +9,6 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.os.SystemClock; import io.sentry.FullyDisplayedReporter; import io.sentry.IScope; import io.sentry.IScopes; @@ -83,7 +82,6 @@ public final class ActivityLifecycleIntegration private final @NotNull WeakHashMap activitySpanHelpers = new WeakHashMap<>(); private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(new Date(0), 0); - private long lastPausedUptimeMillis = 0; private @Nullable Future ttfdAutoCloseFuture = null; // WeakHashMap isn't thread safe but ActivityLifecycleCallbacks is only called from the @@ -400,7 +398,6 @@ public void onActivityPreCreated( scopes != null ? scopes.getOptions().getDateProvider().now() : AndroidDateUtils.getCurrentSentryDateTime(); - lastPausedUptimeMillis = SystemClock.uptimeMillis(); helper.setOnCreateStartTimestamp(lastPausedTime); } @@ -411,7 +408,6 @@ public void onActivityCreated( onActivityPreCreated(activity, savedInstanceState); } try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - setColdStart(savedInstanceState); if (scopes != null && options != null && options.isEnableScreenTracking()) { final @Nullable String activityClassName = ClassUtil.getClassName(activity); scopes.configureScope(scope -> scope.setScreen(activityClassName)); @@ -516,7 +512,6 @@ public void onActivityPrePaused(@NotNull Activity activity) { scopes != null ? scopes.getOptions().getDateProvider().now() : AndroidDateUtils.getCurrentSentryDateTime(); - lastPausedUptimeMillis = SystemClock.uptimeMillis(); } @Override @@ -577,7 +572,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // if the activity is opened again and not in memory, transactions will be created normally. activitiesWithOngoingTransactions.remove(activity); - if (activitiesWithOngoingTransactions.isEmpty()) { + if (activitiesWithOngoingTransactions.isEmpty() && !activity.isChangingConfigurations()) { clear(); } } @@ -586,7 +581,6 @@ public void onActivityDestroyed(final @NotNull Activity activity) { private void clear() { firstActivityCreated = false; lastPausedTime = new SentryNanotimeDate(new Date(0), 0); - lastPausedUptimeMillis = 0; activitySpanHelpers.clear(); } @@ -728,27 +722,6 @@ WeakHashMap getTtfdSpanMap() { return ttfdSpanMap; } - private void setColdStart(final @Nullable Bundle savedInstanceState) { - if (!firstActivityCreated) { - final @NotNull TimeSpan appStartSpan = AppStartMetrics.getInstance().getAppStartTimeSpan(); - // If the app start span already started and stopped, it means the app restarted without - // killing the process, so we are in a warm start - // If the app has an invalid cold start, it means it was started in the background, like - // via BroadcastReceiver, so we consider it a warm start - if ((appStartSpan.hasStarted() && appStartSpan.hasStopped()) - || (!AppStartMetrics.getInstance().isColdStartValid())) { - AppStartMetrics.getInstance().restartAppStart(lastPausedUptimeMillis); - AppStartMetrics.getInstance().setAppStartType(AppStartMetrics.AppStartType.WARM); - } else { - AppStartMetrics.getInstance() - .setAppStartType( - savedInstanceState == null - ? AppStartMetrics.AppStartType.COLD - : AppStartMetrics.AppStartType.WARM); - } - } - } - private @NotNull String getTtidDesc(final @NotNull String activityName) { return activityName + " initial display"; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index ea1f8ae875c..d183d4c45be 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -156,7 +156,7 @@ public static void init( } } if (context.getApplicationContext() instanceof Application) { - appStartMetrics.registerApplicationForegroundCheck( + appStartMetrics.registerLifecycleCallbacks( (Application) context.getApplicationContext()); } final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index 6658e145605..cdb5a13c278 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -174,8 +174,9 @@ private void onAppLaunched( // performance v2: Uses Process.getStartUptimeMillis() // requires API level 24+ - if (buildInfoProvider.getSdkInfoVersion() < android.os.Build.VERSION_CODES.N) { - return; + if (buildInfoProvider.getSdkInfoVersion() >= android.os.Build.VERSION_CODES.N) { + final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); + appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); } if (context instanceof Application) { @@ -185,8 +186,6 @@ private void onAppLaunched( return; } - final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); - appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); - appStartMetrics.registerApplicationForegroundCheck(app); + appStartMetrics.registerLifecycleCallbacks(app); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 5ee32b6f7bd..a2ca74b3607 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -12,11 +12,12 @@ import androidx.annotation.VisibleForTesting; import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; -import io.sentry.SentryDate; -import io.sentry.SentryNanotimeDate; +import io.sentry.NoOpLogger; import io.sentry.TracesSamplingDecision; +import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.internal.util.FirstDrawDoneListener; import io.sentry.util.AutoClosableReentrantLock; import java.util.ArrayList; import java.util.Collections; @@ -24,6 +25,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -32,6 +35,9 @@ * An in-memory representation for app-metrics during app start. As the SDK can't be initialized * that early, we can't use transactions or spans directly. Thus simple TimeSpans are used and later * transformed into SDK specific txn/span data structures. + * + *

This class is also responsible for - determining the app start type (cold, warm) - determining + * if the app was launched in foreground */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { @@ -49,7 +55,7 @@ public enum AppStartType { new AutoClosableReentrantLock(); private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; - private boolean appLaunchedInForeground = false; + private boolean appLaunchedInForeground; private final @NotNull TimeSpan appStartSpan; private final @NotNull TimeSpan sdkInitTimeSpan; @@ -58,10 +64,10 @@ public enum AppStartType { private final @NotNull List activityLifecycles; private @Nullable ITransactionProfiler appStartProfiler = null; private @Nullable TracesSamplingDecision appStartSamplingDecision = null; - private @Nullable SentryDate onCreateTime = null; - private boolean appLaunchTooLong = false; private boolean isCallbackRegistered = false; private boolean shouldSendStartMeasurements = true; + private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); + private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -135,10 +141,6 @@ public boolean isAppLaunchedInForeground() { return appLaunchedInForeground; } - public boolean isColdStartValid() { - return appLaunchedInForeground && !appLaunchTooLong; - } - @VisibleForTesting public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground = appLaunchedInForeground; @@ -172,17 +174,7 @@ public void onAppStartSpansSent() { } public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements; - } - - public void restartAppStart(final long uptimeMillis) { - shouldSendStartMeasurements = true; - appLaunchTooLong = false; - appLaunchedInForeground = true; - appStartSpan.reset(); - appStartSpan.start(); - appStartSpan.setStartedAt(uptimeMillis); - CLASS_LOADED_UPTIME_MS = appStartSpan.getStartUptimeMs(); + return shouldSendStartMeasurements && appLaunchedInForeground; } public long getClassLoadedUptimeMs() { @@ -195,20 +187,27 @@ public long getClassLoadedUptimeMs() { */ public @NotNull TimeSpan getAppStartTimeSpanWithFallback( final @NotNull SentryAndroidOptions options) { - // If the app launch took too long or it was launched in the background we return an empty span - if (!isColdStartValid()) { - return new TimeSpan(); - } - if (options.isEnablePerformanceV2()) { - // Only started when sdk version is >= N - final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); - if (appStartSpan.hasStarted()) { - return appStartSpan; + // If the app start type was never determined or app wasn't launched in foreground, + // the app start is considered invalid + if (appStartType != AppStartType.UNKNOWN && appLaunchedInForeground) { + if (options.isEnablePerformanceV2()) { + // Only started when sdk version is >= N + final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); + if (appStartSpan.hasStarted() + && appStartSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return appStartSpan; + } + } + + // fallback: use sdk init time span, as it will always have a start time set + final @NotNull TimeSpan sdkInitTimeSpan = getSdkInitTimeSpan(); + if (sdkInitTimeSpan.hasStarted() + && sdkInitTimeSpan.getDurationMs() <= TimeUnit.MINUTES.toMillis(1)) { + return sdkInitTimeSpan; } } - // fallback: use sdk init time span, as it will always have a start time set - return getSdkInitTimeSpan(); + return new TimeSpan(); } @TestOnly @@ -224,11 +223,11 @@ public void clear() { } appStartProfiler = null; appStartSamplingDecision = null; - appLaunchTooLong = false; appLaunchedInForeground = false; - onCreateTime = null; isCallbackRegistered = false; shouldSendStartMeasurements = true; + firstDrawDone.set(false); + activeActivitiesCounter.set(0); } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -266,7 +265,23 @@ public static void onApplicationCreate(final @NotNull Application application) { final @NotNull AppStartMetrics instance = getInstance(); if (instance.applicationOnCreate.hasNotStarted()) { instance.applicationOnCreate.setStartedAt(now); - instance.registerApplicationForegroundCheck(application); + instance.registerLifecycleCallbacks(application); + } + } + + /** + * Called by instrumentation + * + * @param application The application object where onCreate was called on + * @noinspection unused + */ + public static void onApplicationPostCreate(final @NotNull Application application) { + final long now = SystemClock.uptimeMillis(); + + final @NotNull AppStartMetrics instance = getInstance(); + if (instance.applicationOnCreate.hasNotStopped()) { + instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); + instance.applicationOnCreate.setStoppedAt(now); } } @@ -275,7 +290,7 @@ public static void onApplicationCreate(final @NotNull Application application) { * * @param application The application object to register the callback to */ - public void registerApplicationForegroundCheck(final @NotNull Application application) { + public void registerLifecycleCallbacks(final @NotNull Application application) { if (isCallbackRegistered) { return; } @@ -286,15 +301,15 @@ public void registerApplicationForegroundCheck(final @NotNull Application applic // (possibly others) the first task posted on the main thread is called before the // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate // callback is called before the application one. - new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain(application)); + new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain()); } - private void checkCreateTimeOnMain(final @NotNull Application application) { + private void checkCreateTimeOnMain() { new Handler(Looper.getMainLooper()) .post( () -> { // if no activity has ever been created, app was launched in background - if (onCreateTime == null) { + if (activeActivitiesCounter.get() == 0) { appLaunchedInForeground = false; // we stop the app start profiler, as it's useless and likely to timeout @@ -303,43 +318,56 @@ private void checkCreateTimeOnMain(final @NotNull Application application) { appStartProfiler = null; } } - application.unregisterActivityLifecycleCallbacks(instance); }); } @Override public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { - // An activity already called onCreate() - if (!appLaunchedInForeground || onCreateTime != null) { + final long nowUptimeMs = SystemClock.uptimeMillis(); + + // the first activity determines the app start type + if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { + // If the app (process) was launched more than 1 minute ago, it's likely wrong + final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); + if (!appLaunchedInForeground || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { + appStartType = AppStartType.WARM; + + shouldSendStartMeasurements = true; + appStartSpan.reset(); + appStartSpan.start(); + appStartSpan.setStartedAt(nowUptimeMs); + CLASS_LOADED_UPTIME_MS = nowUptimeMs; + contentProviderOnCreates.clear(); + applicationOnCreate.reset(); + } else { + appStartType = savedInstanceState == null ? AppStartType.COLD : AppStartType.WARM; + } + } + appLaunchedInForeground = true; + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + if (firstDrawDone.get()) { return; } - onCreateTime = new SentryNanotimeDate(); - - final long spanStartMillis = appStartSpan.getStartTimestampMs(); - final long spanEndMillis = - appStartSpan.hasStopped() - ? appStartSpan.getProjectedStopTimestampMs() - : System.currentTimeMillis(); - final long durationMillis = spanEndMillis - spanStartMillis; - // If the app was launched more than 1 minute ago, it's likely wrong - if (durationMillis > TimeUnit.MINUTES.toMillis(1)) { - appLaunchTooLong = true; + if (activity.getWindow() != null) { + FirstDrawDoneListener.registerForNextDraw( + activity, () -> onFirstFrameDrawn(), new BuildInfoProvider(NoOpLogger.getInstance())); + } else { + new Handler(Looper.getMainLooper()).post(() -> onFirstFrameDrawn()); } } - /** - * Called by instrumentation - * - * @param application The application object where onCreate was called on - * @noinspection unused - */ - public static void onApplicationPostCreate(final @NotNull Application application) { - final long now = SystemClock.uptimeMillis(); - - final @NotNull AppStartMetrics instance = getInstance(); - if (instance.applicationOnCreate.hasNotStopped()) { - instance.applicationOnCreate.setDescription(application.getClass().getName() + ".onCreate"); - instance.applicationOnCreate.setStoppedAt(now); + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + final int remainingActivities = activeActivitiesCounter.decrementAndGet(); + // if the app is moving into background + // as the next Activity is considered like a new app start + if (remainingActivities == 0 && !activity.isChangingConfigurations()) { + appLaunchedInForeground = false; + shouldSendStartMeasurements = true; + firstDrawDone.set(false); } } @@ -373,4 +401,12 @@ public static void onContentProviderPostCreate(final @NotNull ContentProvider co measurement.setStoppedAt(now); } } + + synchronized void onFirstFrameDrawn() { + if (!firstDrawDone.getAndSet(true)) { + final @NotNull AppStartMetrics appStartMetrics = getInstance(); + appStartMetrics.getSdkInitTimeSpan().stop(); + appStartMetrics.getAppStartTimeSpan().stop(); + } + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 317dbc843c2..a3d5d99bf6a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -54,7 +54,6 @@ import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import java.util.Date import java.util.concurrent.Future -import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -94,7 +93,10 @@ class ActivityLifecycleIntegrationTest { whenever(scopes.options).thenReturn(options) - AppStartMetrics.getInstance().isAppLaunchedInForeground = true + val metrics = AppStartMetrics.getInstance() + metrics.isAppLaunchedInForeground = true + metrics.appStartTimeSpan.start() + // We let the ActivityLifecycleIntegration create the proper transaction here val optionCaptor = argumentCaptor() val contextCaptor = argumentCaptor() @@ -626,45 +628,6 @@ class ActivityLifecycleIntegrationTest { verify(ttfdReporter, never()).registerFullyDrawnListener(any()) } - @Test - fun `App start is Cold when savedInstanceState is null`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - - val activity = mock() - sut.onActivityCreated(activity, null) - - assertEquals(AppStartType.COLD, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `App start is Warm when savedInstanceState is not null`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - - val activity = mock() - val bundle = Bundle() - sut.onActivityCreated(activity, bundle) - - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - - @Test - fun `Do not overwrite App start type after set`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - - val activity = mock() - val bundle = Bundle() - sut.onActivityCreated(activity, bundle) - sut.onActivityCreated(activity, null) - - assertEquals(AppStartType.WARM, AppStartMetrics.getInstance().appStartType) - } - @Test fun `When firstActivityCreated is false, start transaction with given appStartTime`() { val sut = fixture.getSut() @@ -920,129 +883,6 @@ class ActivityLifecycleIntegrationTest { ) } - @Test - fun `When firstActivityCreated is false and bundle is not null, start app start warm span with given appStartTime`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityPreCreated(activity, fixture.bundle) - sut.onActivityCreated(activity, fixture.bundle) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and bundle is not null, start app start cold span with given appStartTime`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.cold") - assertEquals(span.description, "Cold Start") - assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and app started more than 1 minute ago, start app with Warm start`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - val duration = TimeUnit.MINUTES.toMillis(1) + 2 - val durationNanos = TimeUnit.MILLISECONDS.toNanos(duration) - val stopDate = SentryNanotimeDate(Date(duration), durationNanos) - setAppStartTime(date, stopDate) - - val activity = mock() - sut.onActivityPreCreated(activity, null) - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertNotEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is false and app started in background, start app with Warm start`() { - val sut = fixture.getSut() - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - sut.setFirstActivityCreated(false) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityPreCreated(activity, null) - sut.onActivityCreated(activity, null) - - val span = fixture.transaction.children.first() - assertEquals(span.operation, "app.start.warm") - assertEquals(span.description, "Warm Start") - assertNotEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) - } - - @Test - fun `When firstActivityCreated is true and app started more than 1 minute ago, app start spans are dropped`() { - val sut = fixture.getSut() - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - - val date = SentryNanotimeDate(Date(1), 0) - val duration = TimeUnit.MINUTES.toMillis(1) + 2 - val durationNanos = TimeUnit.MILLISECONDS.toNanos(duration) - val stopDate = SentryNanotimeDate(Date(duration), durationNanos) - setAppStartTime(date, stopDate) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val appStartSpan = fixture.transaction.children.firstOrNull { - it.description == "Cold Start" - } - assertNull(appStartSpan) - } - - @Test - fun `When firstActivityCreated is true and app started in background, app start spans are dropped`() { - val sut = fixture.getSut() - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.scopes, fixture.options) - - val date = SentryNanotimeDate(Date(1), 0) - setAppStartTime(date) - - val activity = mock() - sut.onActivityCreated(activity, null) - - val appStartSpan = fixture.transaction.children.firstOrNull { - it.description == "Cold Start" - } - assertNull(appStartSpan) - } - @Test fun `When firstActivityCreated is true, start transaction but not with given appStartTime`() { val sut = fixture.getSut() @@ -1568,7 +1408,6 @@ class ActivityLifecycleIntegrationTest { assertEquals(startDate.nanoTimestamp(), sut.getProperty("lastPausedTime").nanoTimestamp()) sut.onActivityCreated(activity, null) - assertNotNull(sut.appStartSpan) sut.onActivityPostCreated(activity, null) assertTrue(helper.onCreateSpan!!.isFinished) @@ -1726,23 +1565,13 @@ class ActivityLifecycleIntegrationTest { appStartMetrics.appStartTimeSpan.stop() sut.register(fixture.scopes, fixture.options) - assertEquals(0, sut.getProperty("lastPausedUptimeMillis")) + assertEquals(0, sut.getProperty("lastPausedTime").nanoTimestamp()) // An Activity (the first) is created after app start has finished sut.onActivityPreCreated(activity, null) // lastPausedUptimeMillis is set to current SystemClock.uptimeMillis() - val lastUptimeMillis = sut.getProperty("lastPausedUptimeMillis") - assertNotEquals(0, lastUptimeMillis) - - sut.onActivityPreCreated(activity, null) - sut.onActivityCreated(activity, null) - // AppStartMetrics app start time is set to Activity preCreated timestamp - assertEquals(lastUptimeMillis, appStartMetrics.appStartTimeSpan.startUptimeMs) - // AppStart type is considered warm - assertEquals(AppStartType.WARM, appStartMetrics.appStartType) - - // Activity appStart span timestamp is the same of AppStartMetrics.appStart timestamp - assertEquals(sut.appStartSpan!!.startDate.nanoTimestamp(), appStartMetrics.getAppStartTimeSpanWithFallback(fixture.options).startTimestamp!!.nanoTimestamp()) + val lastUptimeMillis = sut.getProperty("lastPausedTime") + assertNotEquals(0, lastUptimeMillis.nanoTimestamp()) } private fun SentryTracer.isFinishing() = getProperty("finishStatus").getProperty("isFinishing") @@ -1756,6 +1585,9 @@ class ActivityLifecycleIntegrationTest { private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null) { // set by SentryPerformanceProvider so forcing it here + AppStartMetrics.getInstance().appStartType = AppStartType.COLD + AppStartMetrics.getInstance().isAppLaunchedInForeground = true + val sdkAppStartTimeSpan = AppStartMetrics.getInstance().sdkInitTimeSpan val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan val millis = DateUtils.nanosToMillis(date.nanoTimestamp().toDouble()).toLong() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index b491dcd088d..510a1a429d1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -74,7 +74,7 @@ class PerformanceAndroidEventProcessorTest { emptyMap(), null ).also { - AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + AppStartMetrics.getInstance().onActivityCreated(mock(), if (coldStart) null else mock()) } @BeforeTest @@ -224,6 +224,7 @@ class PerformanceAndroidEventProcessorTest { fun `adds app start metrics to app start txn`() { // given some app start metrics val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.isAppLaunchedInForeground = true appStartMetrics.appStartType = AppStartType.COLD appStartMetrics.appStartTimeSpan.setStartedAt(123) appStartMetrics.appStartTimeSpan.setStoppedAt(456) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt index 1fb44774f1e..4317751c59a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.app.Application +import android.app.Application.ActivityLifecycleCallbacks import android.content.pm.ProviderInfo import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -16,7 +17,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config @@ -48,6 +48,7 @@ class SentryPerformanceProviderTest { val providerInfo = ProviderInfo() val logger = mock() lateinit var configFile: File + var activityLifecycleCallbacks: MutableList = mutableListOf() fun getSut(sdkVersion: Int = Build.VERSION_CODES.S, authority: String = AUTHORITY, handleFile: ((config: File) -> Unit)? = null): SentryPerformanceProvider { val buildInfoProvider: BuildInfoProvider = mock() @@ -56,7 +57,14 @@ class SentryPerformanceProviderTest { whenever(mockContext.applicationContext).thenReturn(mockContext) configFile = File(sentryCache, Sentry.APP_START_PROFILING_CONFIG_FILE_NAME) handleFile?.invoke(configFile) - + whenever(mockContext.registerActivityLifecycleCallbacks(any())).then { + activityLifecycleCallbacks.add(it.arguments[0] as ActivityLifecycleCallbacks) + return@then Unit + } + whenever(mockContext.unregisterActivityLifecycleCallbacks(any())).then { + activityLifecycleCallbacks.remove(it.arguments[0] as ActivityLifecycleCallbacks) + return@then Unit + } providerInfo.authority = authority return SentryPerformanceProvider(logger, buildInfoProvider).apply { attachInfo(mockContext, providerInfo) @@ -101,6 +109,16 @@ class SentryPerformanceProviderTest { assertTrue(AppStartMetrics.getInstance().appStartTimeSpan.hasStarted()) } + @Test + fun `provider sets both appstart and sdk init start + end times`() { + val provider = fixture.getSut() + provider.onCreate() + + val metrics = AppStartMetrics.getInstance() + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertTrue(metrics.sdkInitTimeSpan.hasStarted()) + } + //region app start profiling @Test fun `when config file does not exists, nothing happens`() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index 86edd79b4f6..81bd8b7945e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -1,9 +1,12 @@ package io.sentry.android.core.performance +import android.app.Activity import android.app.Application import android.content.ContentProvider import android.os.Build +import android.os.Bundle import android.os.Looper +import android.os.SystemClock import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.DateUtils import io.sentry.ITransactionProfiler @@ -78,6 +81,7 @@ class AppStartMetricsTest { @Test fun `if perf-2 is enabled and app start time span is started, appStartTimeSpanWithFallback returns it`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.WARM appStartTimeSpan.start() val options = SentryAndroidOptions().apply { @@ -91,7 +95,12 @@ class AppStartMetricsTest { @Test fun `if perf-2 is disabled but app start time span has started, appStartTimeSpanWithFallback returns the sdk init span instead`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - appStartTimeSpan.start() + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + AppStartMetrics.getInstance().sdkInitTimeSpan.apply { + setStartedAt(123) + setStoppedAt(456) + } + appStartTimeSpan.setStartedAt(123) val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = false @@ -104,8 +113,11 @@ class AppStartMetricsTest { @Test fun `if perf-2 is enabled but app start time span has not started, appStartTimeSpanWithFallback returns the sdk init span instead`() { - val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - assertTrue(appStartTimeSpan.hasNotStarted()) + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + AppStartMetrics.getInstance().sdkInitTimeSpan.apply { + setStartedAt(123) + setStoppedAt(456) + } val options = SentryAndroidOptions().apply { isEnablePerformanceV2 = true @@ -124,6 +136,8 @@ class AppStartMetricsTest { @Test fun `if app is launched in background, appStartTimeSpanWithFallback returns an empty span`() { AppStartMetrics.getInstance().isAppLaunchedInForeground = false + AppStartMetrics.getInstance().appStartType = AppStartMetrics.AppStartType.COLD + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan appStartTimeSpan.start() assertTrue(appStartTimeSpan.hasStarted()) @@ -139,19 +153,65 @@ class AppStartMetricsTest { } @Test - fun `if app is launched in background with perfV2, appStartTimeSpanWithFallback returns an empty span`() { - val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan - appStartTimeSpan.start() - assertTrue(appStartTimeSpan.hasStarted()) - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - - val options = SentryAndroidOptions().apply { - isEnablePerformanceV2 = true + fun `if app is launched in background, but an activity launches later, a new warm start is reported with correct timings`() { + val metrics = AppStartMetrics.getInstance() + metrics.registerLifecycleCallbacks(mock()) + + metrics.contentProviderOnCreateTimeSpans.add( + TimeSpan().apply { + description = "ExampleContentProvider" + setStartedAt(1) + setStoppedAt(2) + } + ) + + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(3) + setStoppedAt(4) } - val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) - assertFalse(timeSpan.hasStarted()) + // when the looper runs + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // but no activity creation happened + // then the app wasn't launched in foreground and nothing should be sent + assertFalse(metrics.isAppLaunchedInForeground) + assertFalse(metrics.shouldSendStartMeasurements()) + + val now = TimeUnit.MINUTES.toMillis(2) + 1234567 + SystemClock.setCurrentTimeMillis(now) + + // once an activity launches + AppStartMetrics.getInstance().onActivityCreated(mock(), null) + + // then it should restart the timespan + assertTrue(metrics.isAppLaunchedInForeground) + assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.appStartTimeSpan.hasStarted()) + assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + assertTrue(metrics.contentProviderOnCreateTimeSpans.isEmpty()) + } + + @Test + fun `if app is launched in background, the first created activity assumes a warm start`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + metrics.registerLifecycleCallbacks(mock()) + + // when the handler callback is executed and no activity was launched + Shadows.shadowOf(Looper.getMainLooper()).idle() + + // isAppLaunchedInForeground should be false + assertFalse(metrics.isAppLaunchedInForeground) + + // but when the first activity launches + metrics.onActivityCreated(mock(), null) + + // then a warm start should be set + assertTrue(metrics.isAppLaunchedInForeground) + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } @Test @@ -175,14 +235,15 @@ class AppStartMetricsTest { @Test fun `if activity is never started, returns an empty span`() { - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan appStartTimeSpan.setStartedAt(1) assertTrue(appStartTimeSpan.hasStarted()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() - val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) + val timeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) assertFalse(timeSpan.hasStarted()) } @@ -192,7 +253,7 @@ class AppStartMetricsTest { whenever(profiler.isRunning).thenReturn(true) AppStartMetrics.getInstance().appStartProfiler = profiler - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -206,7 +267,7 @@ class AppStartMetricsTest { AppStartMetrics.getInstance().appStartProfiler = profiler AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -234,33 +295,26 @@ class AppStartMetricsTest { @Test fun `when multiple registerApplicationForegroundCheck, only one callback is registered to application`() { val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - verify(application, times(1)).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) + verify( + application, + times(1) + ).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) } @Test fun `when registerApplicationForegroundCheck, a callback is registered to application`() { val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) } - @Test - fun `when registerApplicationForegroundCheck, a job is posted on main thread to unregistered the callback`() { - val application = mock() - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) - verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - verify(application, never()).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - Shadows.shadowOf(Looper.getMainLooper()).idle() - verify(application).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) - } - @Test fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // Main thread performs the check and sets the flag to false if no activity was created Shadows.shadowOf(Looper.getMainLooper()).idle() @@ -271,7 +325,7 @@ class AppStartMetricsTest { fun `registerApplicationForegroundCheck keeps foreground state to true if an activity is running`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true - AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) // An activity was created AppStartMetrics.getInstance().onActivityCreated(mock(), null) @@ -280,12 +334,6 @@ class AppStartMetricsTest { assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) } - @Test - fun `isColdStartValid is false if app was launched in background`() { - AppStartMetrics.getInstance().isAppLaunchedInForeground = false - assertFalse(AppStartMetrics.getInstance().isColdStartValid) - } - @Test fun `isColdStartValid is false if app launched in more than 1 minute`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -294,7 +342,6 @@ class AppStartMetricsTest { appStartTimeSpan.setStartedAt(1) appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 2) AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) - assertFalse(AppStartMetrics.getInstance().isColdStartValid) } @Test @@ -310,20 +357,105 @@ class AppStartMetricsTest { } @Test - fun `restartAppStart set measurement flag and clear internal lists`() { + fun `a warm start gets reported after a cold start`() { val appStartMetrics = AppStartMetrics.getInstance() + + // when the first activity launches and gets destroyed + val activity0 = mock() + whenever(activity0.isChangingConfigurations).thenReturn(false) + appStartMetrics.onActivityCreated(activity0, null) + + // then the app start type should be cold and measurements should be sent + assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) + assertTrue(appStartMetrics.shouldSendStartMeasurements()) + + // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - appStartMetrics.isAppLaunchedInForeground = false assertFalse(appStartMetrics.shouldSendStartMeasurements()) - assertFalse(appStartMetrics.isColdStartValid) - appStartMetrics.restartAppStart(10) + appStartMetrics.onActivityDestroyed(activity0) + // then it should reset sending the measurements for the next warm activity + appStartMetrics.onActivityCreated(mock(), mock()) + assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) assertTrue(appStartMetrics.shouldSendStartMeasurements()) - assertTrue(appStartMetrics.isColdStartValid) - assertTrue(appStartMetrics.appStartTimeSpan.hasStarted()) - assertTrue(appStartMetrics.appStartTimeSpan.hasNotStopped()) - assertEquals(10, appStartMetrics.appStartTimeSpan.startUptimeMs) + } + + @Test + fun `provider sets both appstart and sdk init start + end times`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.start() + metrics.sdkInitTimeSpan.start() + + assertFalse(metrics.appStartTimeSpan.hasStopped()) + assertFalse(metrics.sdkInitTimeSpan.hasStopped()) + + metrics.onFirstFrameDrawn() + + assertTrue(metrics.appStartTimeSpan.hasStopped()) + assertTrue(metrics.sdkInitTimeSpan.hasStopped()) + } + + @Test + fun `Sets app launch type to cold`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + + val app = mock() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), null) + + // then the app start is considered cold + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + + // when any subsequent activity launches + metrics.onActivityCreated(mock(), mock()) + + // then the app start is still considered cold + assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `Sets app launch type to warm if process init was too long ago`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + val app = mock() + metrics.registerLifecycleCallbacks(app) + + // when an activity is created later with a null bundle + SystemClock.setCurrentTimeMillis(TimeUnit.MINUTES.toMillis(2)) + metrics.onActivityCreated(mock(), null) + + // then the app start is considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + } + + @Test + fun `Sets app launch type to warm`() { + val metrics = AppStartMetrics.getInstance() + assertEquals( + AppStartMetrics.AppStartType.UNKNOWN, + AppStartMetrics.getInstance().appStartType + ) + + val app = mock() + metrics.registerLifecycleCallbacks(app) + metrics.onActivityCreated(mock(), mock()) + + // then the app start is considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) + + // when any subsequent activity launches + metrics.onActivityCreated(mock(), null) + + // then the app start is still considered warm + assertEquals(AppStartMetrics.AppStartType.WARM, AppStartMetrics.getInstance().appStartType) } @Test From 70c11a0c324a75513d43319daca0d281bfab6a97 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 17 Mar 2025 09:24:34 +0100 Subject: [PATCH 044/914] Drop device name (#4179) * Drop device name * Update Changelog * Update Changelog --- CHANGELOG.md | 4 +++ .../android/core/AnrV2EventProcessor.java | 3 --- .../io/sentry/android/core/ContextUtils.java | 10 ------- .../sentry/android/core/DeviceInfoUtil.java | 4 --- .../sentry/android/core/DeviceInfoUtilTest.kt | 26 ------------------- 5 files changed, 4 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abf287298b2..2e81bd10e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) - Ensure app start type is set, even when ActivityLifecycleIntegration is not running ([#4250](https://github.com/getsentry/sentry-java/pull/4250)) +### Behavioral Changes + +- The user's `device.name` is not reported anymore via the device context, even if `options.isSendDefaultPii` is enabled ([#4179](https://github.com/getsentry/sentry-java/pull/4179)) + ### Dependencies - Bump Gradle from v8.12.1 to v8.13.0 ([#4209](https://github.com/getsentry/sentry-java/pull/4209)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index d3c6bd31119..ada10a38f52 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -639,9 +639,6 @@ private void setDevice(final @NotNull SentryBaseEvent event) { @SuppressLint("NewApi") private @NotNull Device getDevice() { Device device = new Device(); - if (options.isSendDefaultPii()) { - device.setName(ContextUtils.getDeviceName(context)); - } device.setManufacturer(Build.MANUFACTURER); device.setBrand(Build.BRAND); device.setFamily(ContextUtils.getFamily(options.getLogger())); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index 5dcd901a917..e945df2aa9c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -14,7 +14,6 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.Build; -import android.provider.Settings; import android.util.DisplayMetrics; import io.sentry.ILogger; import io.sentry.SentryLevel; @@ -90,10 +89,6 @@ private ContextUtils() {} // to avoid doing a bunch of Binder calls we use LazyEvaluator to cache the values that are static // during the app process running - private static final @NotNull AndroidLazyEvaluator deviceName = - new AndroidLazyEvaluator<>( - (context) -> Settings.Global.getString(context.getContentResolver(), "device_name")); - private static final @NotNull LazyEvaluator isForegroundImportance = new LazyEvaluator<>( () -> { @@ -403,10 +398,6 @@ public static boolean isForegroundImportance() { } } - static @Nullable String getDeviceName(final @NotNull Context context) { - return deviceName.getValue(context); - } - static @NotNull String[] getArchitectures() { return Build.SUPPORTED_ABIS; } @@ -521,7 +512,6 @@ public static Context getApplicationContext(final @NotNull Context context) { @TestOnly static void resetInstance() { - deviceName.resetValue(); isForegroundImportance.resetValue(); staticPackageInfo33.resetValue(); staticPackageInfo.resetValue(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 3fa131285ec..b2b4c2cdc8a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -101,10 +101,6 @@ public Device collectDeviceInformation( final boolean collectDeviceIO, final boolean collectDynamicData) { // TODO: missing usable memory final @NotNull Device device = new Device(); - - if (options.isSendDefaultPii()) { - device.setName(ContextUtils.getDeviceName(context)); - } device.setManufacturer(Build.MANUFACTURER); device.setBrand(Build.BRAND); device.setFamily(ContextUtils.getFamily(options.getLogger())); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index c01da4b6e73..d54d6ecdef7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -3,7 +3,6 @@ package io.sentry.android.core import android.content.Context import android.content.Intent import android.os.BatteryManager -import android.provider.Settings import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.internal.util.CpuInfoUtils @@ -29,7 +28,6 @@ class DeviceInfoUtilTest { 75 ).putExtra(BatteryManager.EXTRA_PLUGGED, 0) ) - Settings.Global.putString(context.contentResolver, "device_name", "sentry") DeviceInfoUtil.resetInstance() } @@ -51,30 +49,6 @@ class DeviceInfoUtilTest { assertNotNull(deviceInfo.memorySize) } - @Test - fun `does not include device name when PII is disabled`() { - val deviceInfoUtil = DeviceInfoUtil.getInstance( - context, - SentryAndroidOptions().apply { - isSendDefaultPii = false - } - ) - val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) - assertNull(deviceInfo.name) - } - - @Test - fun `does include device name when pii is enabled`() { - val deviceInfoUtil = DeviceInfoUtil.getInstance( - context, - SentryAndroidOptions().apply { - isSendDefaultPii = true - } - ) - val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) - assertNotNull(deviceInfo.name) - } - @Test fun `does include cpu data`() { CpuInfoUtils.getInstance().setCpuMaxFrequencies(listOf(1024)) From 6be3488585e2b8310e99f0dd41ff8f3767b0410b Mon Sep 17 00:00:00 2001 From: Lauri Alanko <53175128+lealanko-rt@users.noreply.github.com> Date: Mon, 17 Mar 2025 18:39:58 +0200 Subject: [PATCH 045/914] Enable symbolication of native stack frames in ANR events (#4061) * Add native ANR button to sentry-samples-android Add a button to trigger ANR by holding a lock too long in native code. This can be used to test native stack frames in ANR events. * Improve native stack frame parsing Handle offsets and deleted files, recognize "???" as a marker for unknown functions. Use named capturing groups for better readability and editability. * Add PC value and platform to native stack frames * Mark JNI method frames as "native" Use the "native" attribute of stack frames to indicate JNI invocation frames, like SentryStackTraceFactory does. * Add debug images to ANR events The images are parsed from the build ids and filenames in the thread dump's stack frames. * Add addr_mode attributes to ANR stack frames The instruction addresses of native stack frames in thread dumps are relative to the image file from which the code is loaded, and there are no absolute mapping addresses of images available. So explicitly inform the Sentry server about the correct images by using a relative "addr_mode" attribute. Also add the attribute to the SentryStackFrame class since it was not yet supported by it. The field documentation is converted from event.schema.json in the sentry server repo. * Add ChangeLog entry for ANR native symbolication * Fix code formatting, make API 21 level compatible, minor improvements * Add ADDR_MODE entries to sentry.api * Update sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java * Update sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java --------- Co-authored-by: Lauri Alanko Co-authored-by: Markus Hintersteiner Co-authored-by: Roman Zavarnitsyn --- CHANGELOG.md | 5 + .../sentry/android/core/AnrV2Integration.java | 24 +++- .../internal/threaddump/ThreadDumpParser.java | 129 ++++++++++++++---- .../android/core/AnrV2IntegrationTest.kt | 9 ++ .../threaddump/ThreadDumpParserTest.kt | 77 ++++++++++- .../src/main/cpp/native-sample.cpp | 25 ++++ .../sentry/samples/android/MainActivity.java | 25 ++++ .../sentry/samples/android/NativeSample.java | 3 + .../src/main/res/layout/activity_main.xml | 6 + .../src/main/res/values/strings.xml | 1 + sentry/api/sentry.api | 3 + .../io/sentry/protocol/SentryStackFrame.java | 29 ++++ .../SentryStackFrameSerializationTest.kt | 1 + .../resources/json/sentry_stack_frame.json | 1 + 14 files changed, 304 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e81bd10e0c..098a728ef37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Features + +- Add native stack frame address information and debug image metadata to ANR events ([#4061](https://github.com/getsentry/sentry-java/pull/4061)) + - This enables symbolication for stripped native code in ANRs + ### Fixes - Reduce excessive CPU usage when serializing breadcrumbs to disk for ANRs ([#4181](https://github.com/getsentry/sentry-java/pull/4181)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 6b66106d3f4..c6d47cadcb4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -23,6 +23,8 @@ import io.sentry.hints.AbnormalExit; import io.sentry.hints.Backfillable; import io.sentry.hints.BlockingFlushHint; +import io.sentry.protocol.DebugImage; +import io.sentry.protocol.DebugMeta; import io.sentry.protocol.Message; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryThread; @@ -267,6 +269,11 @@ private void reportAsSentryEvent( event.setMessage(sentryMessage); } else if (result.type == ParseResult.Type.DUMP) { event.setThreads(result.threads); + if (result.debugImages != null) { + final DebugMeta debugMeta = new DebugMeta(); + debugMeta.setImages(result.debugImages); + event.setDebugMeta(debugMeta); + } } event.setLevel(SentryLevel.FATAL); event.setTimestamp(DateUtils.getDateTime(anrTimestamp)); @@ -311,7 +318,11 @@ private void reportAsSentryEvent( final Lines lines = Lines.readLines(reader); final ThreadDumpParser threadDumpParser = new ThreadDumpParser(options, isBackground); - final List threads = threadDumpParser.parse(lines); + threadDumpParser.parse(lines); + + final @NotNull List threads = threadDumpParser.getThreads(); + final @NotNull List debugImages = threadDumpParser.getDebugImages(); + if (threads.isEmpty()) { // if the list is empty this means the system failed to capture a proper thread dump of // the android threads, and only contains kernel-level threads and statuses, those ANRs @@ -319,7 +330,7 @@ private void reportAsSentryEvent( // fall back to not reporting them return new ParseResult(ParseResult.Type.NO_DUMP); } - return new ParseResult(ParseResult.Type.DUMP, dump, threads); + return new ParseResult(ParseResult.Type.DUMP, dump, threads, debugImages); } catch (Throwable e) { options.getLogger().log(SentryLevel.WARNING, "Failed to parse ANR thread dump", e); return new ParseResult(ParseResult.Type.ERROR, dump); @@ -403,24 +414,31 @@ enum Type { final Type type; final byte[] dump; final @Nullable List threads; + final @Nullable List debugImages; ParseResult(final @NotNull Type type) { this.type = type; this.dump = null; this.threads = null; + this.debugImages = null; } ParseResult(final @NotNull Type type, final byte[] dump) { this.type = type; this.dump = dump; this.threads = null; + this.debugImages = null; } ParseResult( - final @NotNull Type type, final byte[] dump, final @Nullable List threads) { + final @NotNull Type type, + final byte[] dump, + final @Nullable List threads, + final @Nullable List debugImages) { this.type = type; this.dump = dump; this.threads = threads; + this.debugImages = debugImages; } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java index 43d729b78b6..922828c2075 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/threaddump/ThreadDumpParser.java @@ -22,9 +22,14 @@ import io.sentry.SentryLockReason; import io.sentry.SentryOptions; import io.sentry.SentryStackTraceFactory; +import io.sentry.protocol.DebugImage; import io.sentry.protocol.SentryStackFrame; import io.sentry.protocol.SentryStackTrace; import io.sentry.protocol.SentryThread; +import java.math.BigInteger; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -42,12 +47,40 @@ public class ThreadDumpParser { private static final Pattern BEGIN_UNMANAGED_NATIVE_THREAD_RE = Pattern.compile("\"(.*)\" (.*) ?sysTid=(\\d+)"); + // For reference, see native_stack_dump.cc and tombstone_proto_to_text.cpp in Android sources + // Groups + // 0:entire regex + // 1:index + // 2:pc + // 3:mapinfo + // 4:filename + // 5:mapoffset + // 6:function + // 7:fnoffset + // 8:buildid private static final Pattern NATIVE_RE = Pattern.compile( - " *(?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*?)\\s+\\((.*)\\+(\\d+)\\)(?: \\(.*\\))?"); - private static final Pattern NATIVE_NO_LOC_RE = - Pattern.compile( - " *(?:native: )?#\\d+ \\S+ [0-9a-fA-F]+\\s+(.*)\\s*\\(?(.*)\\)?(?: \\(.*\\))?"); + // " native: #12 pc 0xabcd1234" + " *(?:native: )?#(\\d+) \\S+ ([0-9a-fA-F]+)" + // The map info includes a filename and an optional offset into the file + + ("\\s+(" + // "/path/to/file.ext", + + "(.*?)" + // optional " (deleted)" suffix (deleted files) needed here to bias regex + // correctly + + "(?:\\s+\\(deleted\\))?" + // " (offset 0xabcd1234)", if the mapping is not into the beginning of the file + + "(?:\\s+\\(offset (.*?)\\))?" + + ")") + // Optional function + + ("(?:\\s+\\((?:" + + "\\?\\?\\?" // " (???) marks a missing function, so don't capture it in a group + + "|(.*?)(?:\\+(\\d+))?" // " (func+1234)", offset is + // optional + + ")\\))?") + // Optional " (BuildId: abcd1234abcd1234abcd1234abcd1234abcd1234)" + + "(?:\\s+\\(BuildId: (.*?)\\))?"); + private static final Pattern JAVA_RE = Pattern.compile(" *at (?:(.+)\\.)?([^.]+)\\.([^.]+)\\((.*):([\\d-]+)\\)"); private static final Pattern JNI_RE = @@ -75,15 +108,48 @@ public class ThreadDumpParser { private final @NotNull SentryStackTraceFactory stackTraceFactory; + private final @NotNull Map debugImages; + + private final @NotNull List threads; + public ThreadDumpParser(final @NotNull SentryOptions options, final boolean isBackground) { this.options = options; this.isBackground = isBackground; this.stackTraceFactory = new SentryStackTraceFactory(options); + this.debugImages = new HashMap<>(); + this.threads = new ArrayList<>(); + } + + @NotNull + public List getDebugImages() { + return new ArrayList<>(debugImages.values()); } @NotNull - public List parse(final @NotNull Lines lines) { - final List sentryThreads = new ArrayList<>(); + public List getThreads() { + return threads; + } + + @Nullable + private static String buildIdToDebugId(final @NotNull String buildId) { + try { + // Abuse BigInteger as a hex string parser. Extra byte needed to handle leading zeros. + final ByteBuffer buf = ByteBuffer.wrap(new BigInteger("10" + buildId, 16).toByteArray()); + buf.get(); + return String.format( + "%08x-%04x-%04x-%04x-%04x%08x", + buf.order(ByteOrder.LITTLE_ENDIAN).getInt(), + buf.getShort(), + buf.getShort(), + buf.order(ByteOrder.BIG_ENDIAN).getShort(), + buf.getShort(), + buf.getInt()); + } catch (NumberFormatException | BufferUnderflowException e) { + return null; + } + } + + public void parse(final @NotNull Lines lines) { final Matcher beginManagedThreadRe = BEGIN_MANAGED_THREAD_RE.matcher(""); final Matcher beginUnmanagedNativeThreadRe = BEGIN_UNMANAGED_NATIVE_THREAD_RE.matcher(""); @@ -92,7 +158,7 @@ public List parse(final @NotNull Lines lines) { final Line line = lines.next(); if (line == null) { options.getLogger().log(SentryLevel.WARNING, "Internal error while parsing thread dump."); - return sentryThreads; + return; } final String text = line.text; // we only handle managed threads, as unmanaged/not attached do not have the thread id and @@ -102,11 +168,10 @@ public List parse(final @NotNull Lines lines) { final SentryThread thread = parseThread(lines); if (thread != null) { - sentryThreads.add(thread); + threads.add(thread); } } } - return sentryThreads; } private SentryThread parseThread(final @NotNull Lines lines) { @@ -176,7 +241,6 @@ private SentryStackTrace parseStacktrace( SentryStackFrame lastJavaFrame = null; final Matcher nativeRe = NATIVE_RE.matcher(""); - final Matcher nativeNoLocRe = NATIVE_NO_LOC_RE.matcher(""); final Matcher javaRe = JAVA_RE.matcher(""); final Matcher jniRe = JNI_RE.matcher(""); final Matcher lockedRe = LOCKED_RE.matcher(""); @@ -194,20 +258,7 @@ private SentryStackTrace parseStacktrace( break; } final String text = line.text; - if (matches(nativeRe, text)) { - final SentryStackFrame frame = new SentryStackFrame(); - frame.setPackage(nativeRe.group(1)); - frame.setFunction(nativeRe.group(2)); - frame.setLineno(getInteger(nativeRe, 3, null)); - frames.add(frame); - lastJavaFrame = null; - } else if (matches(nativeNoLocRe, text)) { - final SentryStackFrame frame = new SentryStackFrame(); - frame.setPackage(nativeNoLocRe.group(1)); - frame.setFunction(nativeNoLocRe.group(2)); - frames.add(frame); - lastJavaFrame = null; - } else if (matches(javaRe, text)) { + if (matches(javaRe, text)) { final SentryStackFrame frame = new SentryStackFrame(); final String packageName = javaRe.group(1); final String className = javaRe.group(2); @@ -219,6 +270,31 @@ private SentryStackTrace parseStacktrace( frame.setInApp(stackTraceFactory.isInApp(module)); frames.add(frame); lastJavaFrame = frame; + } else if (matches(nativeRe, text)) { + final SentryStackFrame frame = new SentryStackFrame(); + frame.setPackage(nativeRe.group(3)); + frame.setFunction(nativeRe.group(6)); + frame.setLineno(getInteger(nativeRe, 7, null)); + frame.setInstructionAddr("0x" + nativeRe.group(2)); + frame.setPlatform("native"); + + final String buildId = nativeRe.group(8); + final String debugId = buildId == null ? null : buildIdToDebugId(buildId); + if (debugId != null) { + if (!debugImages.containsKey(debugId)) { + final DebugImage debugImage = new DebugImage(); + debugImage.setDebugId(debugId); + debugImage.setType("elf"); + debugImage.setCodeFile(nativeRe.group(4)); + debugImage.setCodeId(buildId); + debugImages.put(debugId, debugImage); + } + // The addresses in the thread dump are relative to the image + frame.setAddrMode("rel:" + debugId); + } + + frames.add(frame); + lastJavaFrame = null; } else if (matches(jniRe, text)) { final SentryStackFrame frame = new SentryStackFrame(); final String packageName = jniRe.group(1); @@ -227,6 +303,7 @@ private SentryStackTrace parseStacktrace( frame.setModule(module); frame.setFunction(jniRe.group(3)); frame.setInApp(stackTraceFactory.isInApp(module)); + frame.setNative(true); frames.add(frame); lastJavaFrame = frame; } else if (matches(lockedRe, text)) { @@ -334,8 +411,8 @@ private Long getLong( @Nullable private Integer getInteger( - final @NotNull Matcher matcher, final int group, final @Nullable Integer defaultValue) { - final String str = matcher.group(group); + final @NotNull Matcher matcher, final int groupIndex, final @Nullable Integer defaultValue) { + final String str = matcher.group(groupIndex); if (str == null || str.length() == 0) { return defaultValue; } else { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index 68339a4b797..ddc15542535 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -305,6 +305,15 @@ class AnrV2IntegrationTest { ) assertEquals("__start_thread", firstFrame.function) assertEquals(64, firstFrame.lineno) + assertEquals("0x00000000000530b8", firstFrame.instructionAddr) + assertEquals("native", firstFrame.platform) + assertEquals("rel:741f3301-bbb0-b92c-58bd-c15282b8ec7b", firstFrame.addrMode) + + val image = it.debugMeta?.images?.find { + it.debugId == "741f3301-bbb0-b92c-58bd-c15282b8ec7b" + } + assertNotNull(image) + assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) }, argThat { val hint = HintUtils.getSentrySdkHint(this) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt index 19de2e4935d..b5e7adb896a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt @@ -18,7 +18,8 @@ class ThreadDumpParserTest { SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false ) - val threads = parser.parse(lines) + parser.parse(lines) + val threads = parser.threads // just verifying a few important threads, as there are many val main = threads.find { it.name == "main" } assertEquals(1, main!!.id) @@ -73,6 +74,25 @@ class ThreadDumpParserTest { assertEquals("HandlerThread.java", firstFrame.filename) assertEquals(67, firstFrame.lineno) assertEquals(null, firstFrame.isInApp) + assertNull(firstFrame.isNative) + assertNull(firstFrame.platform) + + val jniFrame = randomThread.stacktrace!!.frames!!.get(4) + assertEquals("android.os.MessageQueue", jniFrame.module) + assertEquals("nativePollOnce", jniFrame.function) + assertNull(jniFrame.lineno) + assertEquals(true, jniFrame.isNative) + assertNull(firstFrame.platform) + + val nativeFrame = randomThread.stacktrace!!.frames!!.get(5) + assertEquals("/system/lib64/libandroid_runtime.so", nativeFrame.`package`) + assertEquals( + "android::android_os_MessageQueue_nativePollOnce(_JNIEnv*, _jobject*, long, int)", + nativeFrame.function + ) + assertEquals(44, nativeFrame.lineno) + assertNull(nativeFrame.isNative) // Confusing, but "isNative" means JVM frame for a JNI method + assertEquals("native", nativeFrame.platform) } @Test @@ -82,7 +102,8 @@ class ThreadDumpParserTest { SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false ) - val threads = parser.parse(lines) + parser.parse(lines) + val threads = parser.threads // just verifying a few important threads, as there are many val thread = threads.find { it.name == "samples.android" } assertEquals(9955, thread!!.id) @@ -90,11 +111,57 @@ class ThreadDumpParserTest { assertEquals(false, thread.isCrashed) assertEquals(false, thread.isMain) assertEquals(false, thread.isCurrent) - val lastFrame = thread.stacktrace!!.frames!!.last() + + // Reverse frames so we can index them with the active frame at index 0 + val frames = thread.stacktrace!!.frames!!.reversed() + + val lastFrame = frames.get(0) assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", lastFrame.`package`) assertEquals("syscall", lastFrame.function) assertEquals(28, lastFrame.lineno) assertNull(lastFrame.isInApp) + assertEquals("0x000000000004c35c", lastFrame.instructionAddr) + assertEquals("rel:499d48ba-c085-17cf-3209-da67405662f9", lastFrame.addrMode) + assertEquals("native", lastFrame.platform) + + val nosymFrame = frames.get(21) + assertEquals("/apex/com.android.art/javalib/core-oj.jar", nosymFrame.`package`) + assertNull(nosymFrame.function) + assertNull(nosymFrame.lineno) + assertEquals("0x00000000000ec474", nosymFrame.instructionAddr) + assertNull(nosymFrame.addrMode) + + val spaceFrame = frames.get(14) + assertEquals( + "[anon:dalvik-classes16.dex extracted in memory from /data/app/~~izn1xSZpFlzfVmWi_I0xlQ==" + + "/io.sentry.samples.android-tQSGMNiGA-qdjZm6lPOcNw==/base.apk!classes16.dex]", + spaceFrame.`package` + ) + assertNull(spaceFrame.function) + assertNull(spaceFrame.lineno) + assertEquals("0x00000000000306f0", spaceFrame.instructionAddr) + assertNull(spaceFrame.addrMode) + + val offsetFrame = frames.get(145) + assertEquals("/system/framework/framework.jar (offset 0x12c2000)", offsetFrame.`package`) + assertNull(offsetFrame.function) + assertNull(offsetFrame.lineno) + assertEquals("0x00000000002c8e18", offsetFrame.instructionAddr) + assertNull(offsetFrame.addrMode) + + val deletedFrame = frames.get(117) + assertEquals("/memfd:jit-cache (deleted) (offset 0x2000000)", deletedFrame.`package`) + assertEquals("kotlinx.coroutines.DispatchedTask.run", deletedFrame.function) + assertEquals(1816, deletedFrame.lineno) + assertEquals("0x00000000020b89d8", deletedFrame.instructionAddr) + assertNull(deletedFrame.addrMode) + + val debugImages = parser.debugImages + val image = debugImages.first { image -> image.debugId == "499d48ba-c085-17cf-3209-da67405662f9" } + assertNotNull(image) + assertEquals("499d48ba-c085-17cf-3209-da67405662f9", image.debugId) + assertEquals("/apex/com.android.runtime/lib64/bionic/libc.so", image.codeFile) + assertEquals("ba489d4985c0cf173209da67405662f9", image.codeId) } @Test @@ -104,7 +171,7 @@ class ThreadDumpParserTest { SentryOptions().apply { addInAppInclude("io.sentry.samples") }, false ) - val threads = parser.parse(lines) - assertTrue(threads.isEmpty()) + parser.parse(lines) + assertTrue(parser.threads.isEmpty()) } } diff --git a/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp b/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp index 2a31e6c6e50..de1f0f0d3e1 100644 --- a/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp +++ b/sentry-samples/sentry-samples-android/src/main/cpp/native-sample.cpp @@ -22,4 +22,29 @@ JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_message(JNIEn sentry_capture_event(event); } +[[gnu::noinline]] +static void idle_pointlessly() { + static const volatile int x = 42; + (void)x; +} + +[[gnu::noinline]] +static void loop_eternally() { + while (true) { + idle_pointlessly(); + } +} + +[[gnu::noinline]] +static void keep_object_locked(JNIEnv* env, jobject obj) { + env->MonitorEnter(obj); + loop_eternally(); + env->MonitorExit(obj); +} + +JNIEXPORT void JNICALL Java_io_sentry_samples_android_NativeSample_freezeMysteriously(JNIEnv *env, jclass cls, jobject obj) { + __android_log_print(ANDROID_LOG_WARN, TAG, "About to lock object eternally."); + keep_object_locked(env, obj); +} + } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index da52c72a68d..e881612bd80 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -210,6 +210,31 @@ public void run() { 1000); }); + binding.nativeAnr.setOnClickListener( + view -> { + new Thread( + new Runnable() { + @Override + public void run() { + NativeSample.freezeMysteriously(mutex); + } + }) + .start(); + + new Handler() + .postDelayed( + new Runnable() { + @Override + public void run() { + synchronized (mutex) { + // Shouldn't happen + throw new IllegalStateException(); + } + } + }, + 1000); + }); + binding.openSecondActivity.setOnClickListener( view -> { // finishing so its completely destroyed diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/NativeSample.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/NativeSample.java index bde645c6e31..064818ad4e0 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/NativeSample.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/NativeSample.java @@ -5,6 +5,9 @@ public class NativeSample { public static native void message(); + // Named to demonstrate the value of native stack frames during ANR + public static native void freezeMysteriously(Object obj); + static { System.loadLibrary("native-sample"); } diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index 6fb8d028637..b8a47c6bd59 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -64,6 +64,12 @@ android:layout_height="wrap_content" android:text="@string/anr" /> +