From 5b39391cac7eee210fbecf614954749a9c16082e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 28 Mar 2025 11:00:40 +0100 Subject: [PATCH 001/846] Fix tags missing for compose view hierarchies (#4275) * Fix tags missing for compose view hierarchies * Update Changelog * Extract Jetpack Compose Tag extraction into a single method * Cache internal fields * Transfer Compose Helper to Compose library, Kotlin-ify * Improve bounds calculation for ViewHierarchy and Gesture Target location * Fix formatting * Fix tests * Minor cleanup * Fix lint + Kotlin compiler warnings --- CHANGELOG.md | 1 + build.gradle.kts | 2 +- sentry-android-core/build.gradle.kts | 3 +- .../sentry-uitest-android/build.gradle.kts | 1 - sentry-bom/build.gradle.kts | 3 +- sentry-compose-helper/README.md | 10 -- .../api/sentry-compose-helper.api | 22 --- sentry-compose-helper/build.gradle.kts | 67 ------- .../sentry/compose/SentryComposeHelper.java | 41 ----- .../gestures/ComposeGestureTargetLocator.java | 162 ----------------- .../ComposeViewHierarchyExporter.java | 135 --------------- .../ComposeViewHierarchyExporterTest.java | 106 ------------ sentry-compose/api/android/sentry-compose.api | 20 +++ sentry-compose/build.gradle.kts | 24 +-- sentry-compose/proguard-rules.pro | 1 + .../io/sentry/compose/SentryComposeHelper.kt | 163 ++++++++++++++++++ .../gestures/ComposeGestureTargetLocator.kt | 145 ++++++++++++++++ .../ComposeViewHierarchyExporter.kt | 92 ++++++++++ .../sentry/compose/ComposeIntegrationTests.kt | 110 ++++++++++++ .../compose/SentryModifierComposeTest.kt | 4 +- .../ComposeViewHierarchyExporterTest.kt | 113 ++++++++++++ .../sentry-samples-android/build.gradle.kts | 1 - settings.gradle.kts | 1 - 23 files changed, 651 insertions(+), 576 deletions(-) delete mode 100644 sentry-compose-helper/README.md delete mode 100644 sentry-compose-helper/api/sentry-compose-helper.api delete mode 100644 sentry-compose-helper/build.gradle.kts delete mode 100644 sentry-compose-helper/src/jvmMain/java/io/sentry/compose/SentryComposeHelper.java delete mode 100644 sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java delete mode 100644 sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java delete mode 100644 sentry-compose-helper/src/jvmTest/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.java create mode 100644 sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt create mode 100644 sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt create mode 100644 sentry-compose/src/androidMain/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.kt create mode 100644 sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/ComposeIntegrationTests.kt create mode 100644 sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f004b7d8eba..4d136fd9c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - The `MANIFEST.MF` of `sentry-opentelemetry-agent` now has `Implementation-Version` set to the raw version ([#4291](https://github.com/getsentry/sentry-java/pull/4291)) - An example value would be `8.6.0` - The value of the `Sentry-Version-Name` attribute looks like `sentry-8.5.0-otel-2.10.0` +- Fix tags missing for compose view hierarchies ([#4275](https://github.com/getsentry/sentry-java/pull/4275)) ### Internal diff --git a/build.gradle.kts b/build.gradle.kts index 38ff7b04393..08890a3de69 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-system-test-support" && 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") { apply() apply() diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 7203a0327f0..326b9cd18df 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -81,7 +81,6 @@ dependencies { compileOnly(projects.sentryAndroidTimber) compileOnly(projects.sentryAndroidReplay) compileOnly(projects.sentryCompose) - compileOnly(projects.sentryComposeHelper) // lifecycle processor, session tracking implementation(Config.Libs.lifecycleProcess) @@ -109,7 +108,7 @@ dependencies { testImplementation(projects.sentryAndroidFragment) testImplementation(projects.sentryAndroidTimber) testImplementation(projects.sentryAndroidReplay) - testImplementation(projects.sentryComposeHelper) + testImplementation(projects.sentryCompose) testImplementation(projects.sentryAndroidNdk) testRuntimeOnly(Config.Libs.composeUi) testRuntimeOnly(Config.Libs.timber) diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index c817467a063..22164de9776 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -97,7 +97,6 @@ dependencies { if (applySentryIntegrations) { implementation(projects.sentryAndroid) implementation(projects.sentryCompose) - implementation(projects.sentryComposeHelper) } else { implementation(projects.sentryAndroidCore) } diff --git a/sentry-bom/build.gradle.kts b/sentry-bom/build.gradle.kts index 8af147e82db..48ec2bda58c 100644 --- a/sentry-bom/build.gradle.kts +++ b/sentry-bom/build.gradle.kts @@ -9,8 +9,7 @@ dependencies { .filter { !it.name.startsWith("sentry-samples") && it.name != project.name && - !it.name.contains("test", ignoreCase = true) && - it.name != "sentry-compose-helper" + !it.name.contains("test", ignoreCase = true) } .forEach { project -> evaluationDependsOn(project.path) diff --git a/sentry-compose-helper/README.md b/sentry-compose-helper/README.md deleted file mode 100644 index f5b900ddffb..00000000000 --- a/sentry-compose-helper/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Sentry Compose Helper Library - -This utility library is used to access internal Jetpack Compose APIs using Java. - -Due to [this open issue](https://youtrack.jetbrains.com/issue/KT-30878) you can not have -java sources in a KMP-enabled project which has the android-lib plugin applied. -Thus we place all relevant java code in this library for compilation, -and embed it as part of `sentry-compose`. - -Once the above issue is resolved, the code of this module can be safely moved to `sentry-compose`. diff --git a/sentry-compose-helper/api/sentry-compose-helper.api b/sentry-compose-helper/api/sentry-compose-helper.api deleted file mode 100644 index 058e4312760..00000000000 --- a/sentry-compose-helper/api/sentry-compose-helper.api +++ /dev/null @@ -1,22 +0,0 @@ -public class io/sentry/compose/SentryComposeHelper { - public fun (Lio/sentry/ILogger;)V - public fun getLayoutNodeBoundsInWindow (Landroidx/compose/ui/node/LayoutNode;)Landroidx/compose/ui/geometry/Rect; -} - -public final class io/sentry/compose/gestures/ComposeGestureTargetLocator : io/sentry/internal/gestures/GestureTargetLocator { - public fun (Lio/sentry/ILogger;)V - public fun locate (Ljava/lang/Object;FFLio/sentry/internal/gestures/UiElement$Type;)Lio/sentry/internal/gestures/UiElement; -} - -public final class io/sentry/compose/helper/BuildConfig { - public static final field $stable I - public static final field INSTANCE Lio/sentry/compose/helper/BuildConfig; - public static final field SENTRY_COMPOSE_HELPER_SDK_NAME Ljava/lang/String; - public static final field VERSION_NAME Ljava/lang/String; -} - -public final class io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter : io/sentry/internal/viewhierarchy/ViewHierarchyExporter { - public fun (Lio/sentry/ILogger;)V - public fun export (Lio/sentry/protocol/ViewHierarchyNode;Ljava/lang/Object;)Z -} - diff --git a/sentry-compose-helper/build.gradle.kts b/sentry-compose-helper/build.gradle.kts deleted file mode 100644 index be5637280ec..00000000000 --- a/sentry-compose-helper/build.gradle.kts +++ /dev/null @@ -1,67 +0,0 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - -plugins { - kotlin("multiplatform") - id("org.jetbrains.compose") - `java-library` - id(Config.QualityPlugins.gradleVersions) - id(Config.BuildPlugins.buildConfig) version Config.BuildPlugins.buildConfigVersion -} - -kotlin { - jvm { - withJava() - } - - sourceSets { - val jvmMain by getting { - dependencies { - implementation(projects.sentry) - - compileOnly(compose.runtime) - compileOnly(compose.ui) - - compileOnly(Config.Libs.androidxAnnotation) - } - } - val jvmTest by getting { - dependencies { - implementation(compose.runtime) - implementation(compose.ui) - - compileOnly(Config.Libs.androidxAnnotation) - implementation(Config.TestLibs.kotlinTestJunit) - implementation(Config.TestLibs.mockitoKotlin) - implementation(Config.TestLibs.mockitoInline) - } - } - } -} - -configure { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() -} - -val embeddedJar by configurations.creating { - isCanBeConsumed = true - isCanBeResolved = false -} - -artifacts { - add("embeddedJar", project.layout.buildDirectory.file("libs/sentry-compose-helper-jvm-$version.jar").get().asFile) -} - -buildConfig { - sourceSets.getByName("jvmMain") { - useKotlinOutput() - className("BuildConfig") - packageName("io.sentry.compose.helper") - buildConfigField("String", "SENTRY_COMPOSE_HELPER_SDK_NAME", "\"${Config.Sentry.SENTRY_COMPOSE_HELPER_SDK_NAME}\"") - buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") - } -} diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/SentryComposeHelper.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/SentryComposeHelper.java deleted file mode 100644 index f90e961c897..00000000000 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/SentryComposeHelper.java +++ /dev/null @@ -1,41 +0,0 @@ -package io.sentry.compose; - -import androidx.compose.ui.geometry.Rect; -import androidx.compose.ui.layout.LayoutCoordinatesKt; -import androidx.compose.ui.node.LayoutNode; -import androidx.compose.ui.node.LayoutNodeLayoutDelegate; -import io.sentry.ILogger; -import io.sentry.SentryLevel; -import java.lang.reflect.Field; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public class SentryComposeHelper { - - private final @NotNull ILogger logger; - private Field layoutDelegateField = null; - - public SentryComposeHelper(final @NotNull ILogger logger) { - this.logger = logger; - try { - final Class clazz = Class.forName("androidx.compose.ui.node.LayoutNode"); - layoutDelegateField = clazz.getDeclaredField("layoutDelegate"); - layoutDelegateField.setAccessible(true); - } catch (Exception e) { - logger.log(SentryLevel.WARNING, "Could not find LayoutNode.layoutDelegate field"); - } - } - - public @Nullable Rect getLayoutNodeBoundsInWindow(@NotNull final LayoutNode node) { - if (layoutDelegateField != null) { - try { - final LayoutNodeLayoutDelegate delegate = - (LayoutNodeLayoutDelegate) layoutDelegateField.get(node); - return LayoutCoordinatesKt.boundsInWindow(delegate.getOuterCoordinator().getCoordinates()); - } catch (Exception e) { - logger.log(SentryLevel.WARNING, "Could not fetch position for LayoutNode", e); - } - } - return null; - } -} diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java deleted file mode 100644 index 3c2286298f1..00000000000 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java +++ /dev/null @@ -1,162 +0,0 @@ -package io.sentry.compose.gestures; - -import androidx.compose.ui.Modifier; -import androidx.compose.ui.geometry.Rect; -import androidx.compose.ui.layout.ModifierInfo; -import androidx.compose.ui.node.LayoutNode; -import androidx.compose.ui.node.Owner; -import androidx.compose.ui.semantics.SemanticsConfiguration; -import androidx.compose.ui.semantics.SemanticsModifier; -import androidx.compose.ui.semantics.SemanticsPropertyKey; -import io.sentry.ILogger; -import io.sentry.ISentryLifecycleToken; -import io.sentry.SentryIntegrationPackageStorage; -import io.sentry.compose.SentryComposeHelper; -import io.sentry.compose.helper.BuildConfig; -import io.sentry.internal.gestures.GestureTargetLocator; -import io.sentry.internal.gestures.UiElement; -import io.sentry.util.AutoClosableReentrantLock; -import java.lang.reflect.Field; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Queue; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@SuppressWarnings("KotlinInternalInJava") -public final class ComposeGestureTargetLocator implements GestureTargetLocator { - - private static final String ORIGIN = "jetpack_compose"; - - private final @NotNull ILogger logger; - private volatile @Nullable SentryComposeHelper composeHelper; - private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); - - static { - SentryIntegrationPackageStorage.getInstance() - .addPackage("maven:io.sentry:sentry-compose", BuildConfig.VERSION_NAME); - } - - public ComposeGestureTargetLocator(final @NotNull ILogger logger) { - this.logger = logger; - SentryIntegrationPackageStorage.getInstance().addIntegration("ComposeUserInteraction"); - } - - @Override - public @Nullable UiElement locate( - @Nullable Object root, float x, float y, UiElement.Type targetType) { - - // lazy init composeHelper as it's using some reflection under the hood - if (composeHelper == null) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - if (composeHelper == null) { - composeHelper = new SentryComposeHelper(logger); - } - } - } - - if (!(root instanceof Owner)) { - return null; - } - - final @NotNull Queue queue = new LinkedList<>(); - queue.add(((Owner) root).getRoot()); - - // the final tag to return - @Nullable String targetTag = null; - - // the last known tag when iterating the node tree - @Nullable String lastKnownTag = null; - while (!queue.isEmpty()) { - final @Nullable LayoutNode node = queue.poll(); - if (node == null) { - continue; - } - - if (node.isPlaced() && layoutNodeBoundsContain(composeHelper, node, x, y)) { - boolean isClickable = false; - boolean isScrollable = false; - - final List modifiers = node.getModifierInfo(); - for (ModifierInfo modifierInfo : modifiers) { - if (modifierInfo.getModifier() instanceof SemanticsModifier) { - final SemanticsModifier semanticsModifierCore = - (SemanticsModifier) modifierInfo.getModifier(); - final SemanticsConfiguration semanticsConfiguration = - semanticsModifierCore.getSemanticsConfiguration(); - for (Map.Entry, ?> entry : semanticsConfiguration) { - final @Nullable String key = entry.getKey().getName(); - if ("ScrollBy".equals(key)) { - isScrollable = true; - } else if ("OnClick".equals(key)) { - isClickable = true; - } else if ("SentryTag".equals(key) || "TestTag".equals(key)) { - if (entry.getValue() instanceof String) { - lastKnownTag = (String) entry.getValue(); - } - } - } - } else { - final @NotNull Modifier modifier = modifierInfo.getModifier(); - // Newer Jetpack Compose 1.5 uses Node modifiers for clicks/scrolls - final @Nullable String type = modifier.getClass().getCanonicalName(); - if ("androidx.compose.foundation.ClickableElement".equals(type) - || "androidx.compose.foundation.CombinedClickableElement".equals(type)) { - isClickable = true; - } else if ("androidx.compose.foundation.ScrollingLayoutElement".equals(type)) { - isScrollable = true; - } else if ("androidx.compose.ui.platform.TestTagElement".equals(type)) { - // Newer Jetpack Compose uses TestTagElement as node elements - // See - // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/TestTag.kt;l=34;drc=dcaa116fbfda77e64a319e1668056ce3b032469f - try { - final Field tagField = modifier.getClass().getDeclaredField("tag"); - tagField.setAccessible(true); - final @Nullable Object value = tagField.get(modifier); - if (value instanceof String) { - lastKnownTag = (String) value; - } - } catch (Throwable e) { - // ignored - } - } - } - } - - if (isClickable && targetType == UiElement.Type.CLICKABLE) { - targetTag = lastKnownTag; - } - if (isScrollable && targetType == UiElement.Type.SCROLLABLE) { - targetTag = lastKnownTag; - // skip any children for scrollable targets - break; - } - } - queue.addAll(node.getZSortedChildren().asMutableList()); - } - - if (targetTag == null) { - return null; - } else { - return new UiElement(null, null, null, targetTag, ORIGIN); - } - } - - private static boolean layoutNodeBoundsContain( - @NotNull SentryComposeHelper composeHelper, - @NotNull LayoutNode node, - final float x, - final float y) { - - final @Nullable Rect bounds = composeHelper.getLayoutNodeBoundsInWindow(node); - if (bounds == null) { - return false; - } else { - return x >= bounds.getLeft() - && x <= bounds.getRight() - && y >= bounds.getTop() - && y <= bounds.getBottom(); - } - } -} diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java deleted file mode 100644 index 6568b495c35..00000000000 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java +++ /dev/null @@ -1,135 +0,0 @@ -package io.sentry.compose.viewhierarchy; - -import androidx.compose.runtime.collection.MutableVector; -import androidx.compose.ui.geometry.Rect; -import androidx.compose.ui.layout.ModifierInfo; -import androidx.compose.ui.node.LayoutNode; -import androidx.compose.ui.node.Owner; -import androidx.compose.ui.semantics.SemanticsConfiguration; -import androidx.compose.ui.semantics.SemanticsModifier; -import androidx.compose.ui.semantics.SemanticsPropertyKey; -import io.sentry.ILogger; -import io.sentry.ISentryLifecycleToken; -import io.sentry.compose.SentryComposeHelper; -import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; -import io.sentry.protocol.ViewHierarchyNode; -import io.sentry.util.AutoClosableReentrantLock; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@SuppressWarnings("KotlinInternalInJava") -public final class ComposeViewHierarchyExporter implements ViewHierarchyExporter { - - @NotNull private final ILogger logger; - @Nullable private volatile SentryComposeHelper composeHelper; - private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); - - public ComposeViewHierarchyExporter(@NotNull final ILogger logger) { - this.logger = logger; - } - - @Override - public boolean export(@NotNull final ViewHierarchyNode parent, @NotNull final Object element) { - - if (!(element instanceof Owner)) { - return false; - } - - // lazy init composeHelper as it's using some reflection under the hood - if (composeHelper == null) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - if (composeHelper == null) { - composeHelper = new SentryComposeHelper(logger); - } - } - } - - final @NotNull LayoutNode rootNode = ((Owner) element).getRoot(); - addChild(composeHelper, parent, null, rootNode); - return true; - } - - private static void addChild( - @NotNull final SentryComposeHelper composeHelper, - @NotNull final ViewHierarchyNode parent, - @Nullable final LayoutNode parentNode, - @NotNull final LayoutNode node) { - if (node.isPlaced()) { - final ViewHierarchyNode vhNode = new ViewHierarchyNode(); - setTag(node, vhNode); - setBounds(composeHelper, node, parentNode, vhNode); - - if (vhNode.getTag() != null) { - vhNode.setType(vhNode.getTag()); - } else { - vhNode.setType("@Composable"); - } - - if (parent.getChildren() == null) { - parent.setChildren(new ArrayList<>()); - } - parent.getChildren().add(vhNode); - - final MutableVector children = node.getZSortedChildren(); - final int childrenCount = children.getSize(); - for (int i = 0; i < childrenCount; i++) { - final LayoutNode child = children.get(i); - addChild(composeHelper, vhNode, node, child); - } - } - } - - private static void setTag( - final @NotNull LayoutNode node, final @NotNull ViewHierarchyNode vhNode) { - final List modifiers = node.getModifierInfo(); - for (ModifierInfo modifierInfo : modifiers) { - if (modifierInfo.getModifier() instanceof SemanticsModifier) { - final SemanticsModifier semanticsModifierCore = - (SemanticsModifier) modifierInfo.getModifier(); - final SemanticsConfiguration semanticsConfiguration = - semanticsModifierCore.getSemanticsConfiguration(); - for (Map.Entry, ?> entry : semanticsConfiguration) { - final @Nullable String key = entry.getKey().getName(); - if ("SentryTag".equals(key) || "TestTag".equals(key)) { - if (entry.getValue() instanceof String) { - vhNode.setTag((String) entry.getValue()); - } - } - } - } - } - } - - private static void setBounds( - final @NotNull SentryComposeHelper composeHelper, - final @NotNull LayoutNode node, - final @Nullable LayoutNode parentNode, - final @NotNull ViewHierarchyNode vhNode) { - - final int nodeHeight = node.getHeight(); - final int nodeWidth = node.getWidth(); - - vhNode.setHeight((double) nodeHeight); - vhNode.setWidth((double) nodeWidth); - - final Rect bounds = composeHelper.getLayoutNodeBoundsInWindow(node); - if (bounds != null) { - double x = bounds.getLeft(); - double y = bounds.getTop(); - // layout coordinates for view hierarchy are relative to the parent node - if (parentNode != null) { - final @Nullable Rect parentBounds = composeHelper.getLayoutNodeBoundsInWindow(parentNode); - if (parentBounds != null) { - x -= parentBounds.getLeft(); - y -= parentBounds.getTop(); - } - } - - vhNode.setX(x); - vhNode.setY(y); - } - } -} diff --git a/sentry-compose-helper/src/jvmTest/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.java b/sentry-compose-helper/src/jvmTest/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.java deleted file mode 100644 index a7289124811..00000000000 --- a/sentry-compose-helper/src/jvmTest/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package io.sentry.compose.viewhierarchy; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; - -import androidx.compose.runtime.collection.MutableVector; -import androidx.compose.ui.layout.LayoutCoordinates; -import androidx.compose.ui.layout.ModifierInfo; -import androidx.compose.ui.node.LayoutNode; -import androidx.compose.ui.node.Owner; -import androidx.compose.ui.semantics.SemanticsConfiguration; -import androidx.compose.ui.semantics.SemanticsModifier; -import androidx.compose.ui.semantics.SemanticsPropertyKey; -import io.sentry.NoOpLogger; -import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; -import io.sentry.protocol.ViewHierarchyNode; -import java.util.ArrayList; -import java.util.List; -import kotlin.jvm.functions.Function2; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.junit.Test; -import org.mockito.Mockito; - -public class ComposeViewHierarchyExporterTest { - - @Test - public void testComposeViewHierarchyExport() { - final ViewHierarchyNode rootVhNode = new ViewHierarchyNode(); - - final LayoutNode childA = mockLayoutNode(true, "childA", 10, 20); - final LayoutNode childB = mockLayoutNode(true, null, 10, 20); - final LayoutNode childC = mockLayoutNode(false, null, 10, 20); - final LayoutNode parent = mockLayoutNode(true, "root", 30, 40, childA, childB, childC); - - final Owner node = Mockito.mock(Owner.class); - Mockito.when(node.getRoot()).thenReturn(parent); - - final ViewHierarchyExporter exporter = - new ComposeViewHierarchyExporter(NoOpLogger.getInstance()); - exporter.export(rootVhNode, node); - - assertEquals(1, rootVhNode.getChildren().size()); - final ViewHierarchyNode parentVhNode = rootVhNode.getChildren().get(0); - - assertEquals("root", parentVhNode.getTag()); - assertEquals(30.0, parentVhNode.getWidth().doubleValue(), 0.001); - assertEquals(40.0, parentVhNode.getHeight().doubleValue(), 0.001); - - // ensure not placed elements (childC) are not part of the view hierarchy - assertEquals(2, parentVhNode.getChildren().size()); - - final ViewHierarchyNode childAVhNode = parentVhNode.getChildren().get(0); - assertEquals("childA", childAVhNode.getTag()); - assertEquals(10.0, childAVhNode.getWidth().doubleValue(), 0.001); - assertEquals(20.0, childAVhNode.getHeight().doubleValue(), 0.001); - assertNull(childAVhNode.getChildren()); - - final ViewHierarchyNode childBVhNode = parentVhNode.getChildren().get(1); - assertNull(childBVhNode.getTag()); - } - - private static LayoutNode mockLayoutNode( - final boolean isPlaced, - final @Nullable String tag, - final int width, - final int height, - LayoutNode... children) { - final LayoutNode nodeA = Mockito.mock(LayoutNode.class); - Mockito.when(nodeA.isPlaced()).thenReturn(isPlaced); - Mockito.when((nodeA.getWidth())).thenReturn(width); - Mockito.when((nodeA.getHeight())).thenReturn(height); - - final ModifierInfo modifierInfo = Mockito.mock(ModifierInfo.class); - Mockito.when(modifierInfo.getModifier()) - .thenReturn( - new SemanticsModifier() { - @NotNull - @Override - public SemanticsConfiguration getSemanticsConfiguration() { - final SemanticsConfiguration config = new SemanticsConfiguration(); - config.set( - new SemanticsPropertyKey<>( - "SentryTag", - new Function2() { - @Override - public String invoke(String s, String s2) { - return s; - } - }), - tag); - return config; - } - }); - final List modifierInfoList = new ArrayList<>(); - modifierInfoList.add(modifierInfo); - Mockito.when((nodeA.getModifierInfo())).thenReturn(modifierInfoList); - - Mockito.when((nodeA.getZSortedChildren())) - .thenReturn(new MutableVector<>(children, children.length)); - - final LayoutCoordinates coordinates = Mockito.mock(LayoutCoordinates.class); - Mockito.when(nodeA.getCoordinates()).thenReturn(coordinates); - return nodeA; - } -} diff --git a/sentry-compose/api/android/sentry-compose.api b/sentry-compose/api/android/sentry-compose.api index 728e248dd31..59d4828ec7f 100644 --- a/sentry-compose/api/android/sentry-compose.api +++ b/sentry-compose/api/android/sentry-compose.api @@ -6,6 +6,10 @@ public final class io/sentry/compose/BuildConfig { public fun ()V } +public final class io/sentry/compose/SentryComposeHelperKt { + public static final fun boundsInWindow (Landroidx/compose/ui/layout/LayoutCoordinates;Landroidx/compose/ui/layout/LayoutCoordinates;)Landroidx/compose/ui/geometry/Rect; +} + public final class io/sentry/compose/SentryComposeTracingKt { public static final fun SentryTraced (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } @@ -22,3 +26,19 @@ public final class io/sentry/compose/SentryNavigationIntegrationKt { public static final fun withSentryObservableEffect (Landroidx/navigation/NavHostController;ZZLandroidx/compose/runtime/Composer;II)Landroidx/navigation/NavHostController; } +public final class io/sentry/compose/gestures/ComposeGestureTargetLocator : io/sentry/internal/gestures/GestureTargetLocator { + public static final field $stable I + public static final field Companion Lio/sentry/compose/gestures/ComposeGestureTargetLocator$Companion; + public fun (Lio/sentry/ILogger;)V + public fun locate (Ljava/lang/Object;FFLio/sentry/internal/gestures/UiElement$Type;)Lio/sentry/internal/gestures/UiElement; +} + +public final class io/sentry/compose/gestures/ComposeGestureTargetLocator$Companion { +} + +public final class io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter : io/sentry/internal/viewhierarchy/ViewHierarchyExporter { + public static final field $stable I + public fun (Lio/sentry/ILogger;)V + public fun export (Lio/sentry/protocol/ViewHierarchyNode;Ljava/lang/Object;)Z +} + diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index df8c4dae202..cd80f2a8009 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -1,4 +1,4 @@ -import com.android.build.gradle.internal.tasks.LibraryAarJarsTask + import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.dokka.gradle.DokkaTask @@ -42,8 +42,6 @@ kotlin { dependencies { compileOnly(compose.runtime) compileOnly(compose.ui) - - compileOnly(projects.sentryComposeHelper) } } val androidMain by getting { @@ -136,23 +134,3 @@ tasks.withType().configureEach { } } } - -/** - * Due to https://youtrack.jetbrains.com/issue/KT-30878 - * you can not have java sources in a KMP-enabled project which has the android-lib plugin applied. - * Thus we compile relevant java code in sentry-compose-helper first and embed it in here. - */ -val embedComposeHelperConfig by configurations.creating { - isCanBeConsumed = false - isCanBeResolved = true -} - -dependencies { - embedComposeHelperConfig( - project(":" + projects.sentryComposeHelper.name, "embeddedJar") - ) -} - -tasks.withType { - mainScopeClassFiles.setFrom(embedComposeHelperConfig) -} diff --git a/sentry-compose/proguard-rules.pro b/sentry-compose/proguard-rules.pro index 372d2db1db0..666251ddda0 100644 --- a/sentry-compose/proguard-rules.pro +++ b/sentry-compose/proguard-rules.pro @@ -13,6 +13,7 @@ -keepnames class androidx.compose.foundation.CombinedClickableElement -keepnames class androidx.compose.foundation.ScrollingLayoutElement -keepnames class androidx.compose.ui.platform.TestTagElement { *; } +-keepnames class io.sentry.compose.SentryModifier.SentryTagModifierNodeElement { *; } # R8 will warn about missing classes if people don't have androidx.compose-navigation on their # classpath, but this is fine, these classes are used in an internal class which is only used when diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt new file mode 100644 index 00000000000..c22ef056752 --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeHelper.kt @@ -0,0 +1,163 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.semantics.SemanticsModifier +import io.sentry.ILogger +import io.sentry.SentryLevel +import java.lang.reflect.Field + +internal class SentryComposeHelper(logger: ILogger) { + + private val testTagElementField: Field? = + loadField(logger, "androidx.compose.ui.platform.TestTagElement", "tag") + + private val sentryTagElementField: Field? = + loadField(logger, "io.sentry.compose.SentryModifier.SentryTagModifierNodeElement", "tag") + + fun extractTag(modifier: Modifier): String? { + val type = modifier.javaClass.canonicalName + // Newer Jetpack Compose uses TestTagElement as node elements + // See + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/TestTag.kt;l=34;drc=dcaa116fbfda77e64a319e1668056ce3b032469f + try { + if ("androidx.compose.ui.platform.TestTagElement" == type && + testTagElementField != null + ) { + val value = testTagElementField.get(modifier) + return value as String? + } else if ("io.sentry.compose.SentryModifier.SentryTagModifierNodeElement" == type && + sentryTagElementField != null + ) { + val value = sentryTagElementField.get(modifier) + return value as String? + } + } catch (e: Throwable) { + // ignored + } + + // Older versions use SemanticsModifier + if (modifier is SemanticsModifier) { + val semanticsConfiguration = + modifier.semanticsConfiguration + for ((item, value) in semanticsConfiguration) { + val key = item.name + if ("SentryTag" == key || "TestTag" == key) { + if (value is String) { + return value + } + } + } + } + return null + } + + companion object { + private fun loadField( + logger: ILogger, + className: String, + fieldName: String + ): Field? { + try { + val clazz = Class.forName(className) + val field = clazz.getDeclaredField(fieldName) + field.isAccessible = true + return field + } catch (e: Exception) { + logger.log(SentryLevel.WARNING, "Could not load $className.$fieldName field") + } + return null + } + } +} + +/** + * Copied from sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt + * + * A faster copy of https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187 + * + * Since we traverse the tree from the root, we don't need to find it again from the leaf node and + * just pass it as an argument. + * + * @return boundaries of this layout relative to the window's origin. + */ +public fun LayoutCoordinates.boundsInWindow(rootCoordinates: LayoutCoordinates?): Rect { + val root = rootCoordinates ?: findRootCoordinates() + + val rootWidth = root.size.width.toFloat() + val rootHeight = root.size.height.toFloat() + + val bounds = root.localBoundingBoxOf(this) + val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth) + val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight) + val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth) + val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) + + if (boundsLeft == boundsRight || boundsTop == boundsBottom) { + return Rect.Zero + } + + val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) + val topRight = root.localToWindow(Offset(boundsRight, boundsTop)) + val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom)) + val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom)) + + val topLeftX = topLeft.x + val topRightX = topRight.x + val bottomLeftX = bottomLeft.x + val bottomRightX = bottomRight.x + + val left = fastMinOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + val right = fastMaxOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + + val topLeftY = topLeft.y + val topRightY = topRight.y + val bottomLeftY = bottomLeft.y + val bottomRightY = bottomRight.y + + val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + + return Rect(left, top, right, bottom) +} + +/** + * Returns the smaller of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.minOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private fun fastMinOf(a: Float, b: Float, c: Float, d: Float): Float { + return minOf(a, minOf(b, minOf(c, d))) +} + +/** + * Returns the largest of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.maxOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private fun fastMaxOf(a: Float, b: Float, c: Float, d: Float): Float { + return maxOf(a, maxOf(b, maxOf(c, d))) +} + +/** + * Returns this float value clamped in the inclusive range defined by [minimumValue] and + * [maximumValue]. Unlike [Float.coerceIn], the range is not validated: the caller must ensure that + * [minimumValue] is less than [maximumValue]. + */ +private fun Float.fastCoerceIn(minimumValue: Float, maximumValue: Float) = + this.fastCoerceAtLeast(minimumValue).fastCoerceAtMost(maximumValue) + +/** Ensures that this value is not less than the specified [minimumValue]. */ +private fun Float.fastCoerceAtLeast(minimumValue: Float): Float { + return if (this < minimumValue) minimumValue else this +} + +/** Ensures that this value is not greater than the specified [maximumValue]. */ +private fun Float.fastCoerceAtMost(maximumValue: Float): Float { + return if (this > maximumValue) maximumValue else this +} diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt new file mode 100644 index 00000000000..d630bf6ed3b --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt @@ -0,0 +1,145 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.compose.gestures + +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsModifier +import io.sentry.ILogger +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.compose.BuildConfig +import io.sentry.compose.SentryComposeHelper +import io.sentry.compose.boundsInWindow +import io.sentry.internal.gestures.GestureTargetLocator +import io.sentry.internal.gestures.UiElement +import io.sentry.util.AutoClosableReentrantLock +import java.util.LinkedList +import java.util.Queue + +@OptIn(InternalComposeUiApi::class) +public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureTargetLocator { + @Volatile + private var composeHelper: SentryComposeHelper? = null + private val lock = AutoClosableReentrantLock() + + init { + SentryIntegrationPackageStorage.getInstance().addPackage("maven:io.sentry:sentry-compose", BuildConfig.VERSION_NAME) + } + + override fun locate( + root: Any?, + x: Float, + y: Float, + targetType: UiElement.Type + ): UiElement? { + if (root !is Owner) { + return null + } + + // lazy init composeHelper as it's using some reflection under the hood + if (composeHelper == null) { + lock.acquire().use { + if (composeHelper == null) { + composeHelper = SentryComposeHelper(logger) + } + } + } + + val rootLayoutNode = root.root + + val queue: Queue = LinkedList() + queue.add(rootLayoutNode) + + // the final tag to return + var targetTag: String? = null + + // the last known tag when iterating the node tree + var lastKnownTag: String? = null + while (!queue.isEmpty()) { + val node = queue.poll() ?: continue + if (node.isPlaced && layoutNodeBoundsContain( + rootLayoutNode, + node, + x, + y + ) + ) { + var isClickable = false + var isScrollable = false + + val modifiers = node.getModifierInfo() + for (modifierInfo in modifiers) { + val tag = composeHelper!!.extractTag(modifierInfo.modifier) + if (tag != null) { + lastKnownTag = tag + } + + if (modifierInfo.modifier is SemanticsModifier) { + val semanticsModifierCore = + modifierInfo.modifier as SemanticsModifier + val semanticsConfiguration = + semanticsModifierCore.semanticsConfiguration + + for (item in semanticsConfiguration) { + val key: String = item.key.name + if ("ScrollBy" == key) { + isScrollable = true + } else if ("OnClick" == key) { + isClickable = true + } + } + } else { + val modifier = modifierInfo.modifier + // Newer Jetpack Compose 1.5 uses Node modifiers for clicks/scrolls + val type = modifier.javaClass.canonicalName + if ("androidx.compose.foundation.ClickableElement" == type || + "androidx.compose.foundation.CombinedClickableElement" == type + ) { + isClickable = true + } else if ("androidx.compose.foundation.ScrollingLayoutElement" == type) { + isScrollable = true + } + } + } + + if (isClickable && targetType == UiElement.Type.CLICKABLE) { + targetTag = lastKnownTag + } + if (isScrollable && targetType == UiElement.Type.SCROLLABLE) { + targetTag = lastKnownTag + // skip any children for scrollable targets + break + } + } + queue.addAll(node.zSortedChildren.asMutableList()) + } + + return if (targetTag == null) { + null + } else { + UiElement( + null, + null, + null, + targetTag, + ORIGIN + ) + } + } + + private fun layoutNodeBoundsContain( + root: LayoutNode, + node: LayoutNode, + x: Float, + y: Float + ): Boolean { + val bounds = node.coordinates.boundsInWindow(root.coordinates) + return bounds.contains(Offset(x, y)) + } + + public companion object { + private const val ORIGIN = "jetpack_compose" + } +} diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.kt new file mode 100644 index 00000000000..ab6814fbfb3 --- /dev/null +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.kt @@ -0,0 +1,92 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.compose.viewhierarchy + +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import io.sentry.ILogger +import io.sentry.compose.SentryComposeHelper +import io.sentry.internal.viewhierarchy.ViewHierarchyExporter +import io.sentry.protocol.ViewHierarchyNode +import io.sentry.util.AutoClosableReentrantLock + +public class ComposeViewHierarchyExporter public constructor(private val logger: ILogger) : + ViewHierarchyExporter { + @Volatile + private var composeHelper: SentryComposeHelper? = null + private val lock = AutoClosableReentrantLock() + + override fun export(parent: ViewHierarchyNode, element: Any): Boolean { + if (element !is Owner) { + return false + } + + // lazy init composeHelper as it's using some reflection under the hood + if (composeHelper == null) { + lock.acquire().use { + if (composeHelper == null) { + composeHelper = SentryComposeHelper(logger) + } + } + } + + val rootNode = element.root + addChild(composeHelper!!, parent, rootNode, rootNode) + return true + } + + private fun addChild( + composeHelper: SentryComposeHelper, + parent: ViewHierarchyNode, + rootNode: LayoutNode, + node: LayoutNode + ) { + if (node.isPlaced) { + val vhNode = ViewHierarchyNode() + setTag(composeHelper, node, vhNode) + setBounds(node, vhNode) + vhNode.type = vhNode.tag ?: "@Composable" + + if (parent.children == null) { + parent.children = ArrayList() + } + parent.children!!.add(vhNode) + + val children = node.zSortedChildren + val childrenCount = children.size + for (i in 0 until childrenCount) { + val child = children[i] + addChild(composeHelper, vhNode, rootNode, child) + } + } + } + + private fun setTag( + helper: SentryComposeHelper, + node: LayoutNode, + vhNode: ViewHierarchyNode + ) { + // needs to be in-sync with ComposeGestureTargetLocator + val modifiers = node.getModifierInfo() + for (modifierInfo in modifiers) { + val tag = helper.extractTag(modifierInfo.modifier) + if (tag != null) { + vhNode.tag = tag + } + } + } + + private fun setBounds( + node: LayoutNode, + vhNode: ViewHierarchyNode + ) { + // layout coordinates for view hierarchy are relative to the parent node + val bounds = node.coordinates.boundsInParent() + + vhNode.x = bounds.left.toDouble() + vhNode.y = bounds.top.toDouble() + vhNode.height = bounds.height.toDouble() + vhNode.width = bounds.width.toDouble() + } +} diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/ComposeIntegrationTests.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/ComposeIntegrationTests.kt new file mode 100644 index 00000000000..01e96dc0919 --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/ComposeIntegrationTests.kt @@ -0,0 +1,110 @@ +package io.sentry.compose + +import android.app.Application +import android.content.ComponentName +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.core.view.children +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.NoOpLogger +import io.sentry.compose.SentryModifier.sentryTag +import io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter +import io.sentry.protocol.ViewHierarchyNode +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class ComposeIntegrationTests { + + // workaround for robolectric tests with composeRule + // from https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 + @get:Rule(order = 1) + val addActivityToRobolectricRule = object : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + val appContext: Application = ApplicationProvider.getApplicationContext() + Shadows.shadowOf(appContext.packageManager).addActivityIfNotPresent( + ComponentName( + appContext.packageName, + ComponentActivity::class.java.name + ) + ) + } + } + + @get:Rule(order = 2) + val rule = createAndroidComposeRule() + + @Test + fun `Compose View Hierarchy is exported with the correct tags`() { + rule.setContent { + Column { + Box(modifier = Modifier.sentryTag("sentryTag")) + Box(modifier = Modifier.testTag("testTag")) + } + } + + rule.activityRule.scenario.onActivity { activity -> + val exporter = ComposeViewHierarchyExporter(NoOpLogger.getInstance()) + val root = ViewHierarchyNode() + val rootView = activity.findViewById(android.R.id.content) + val rootComposeView = locateAndroidComposeView(rootView) + assertNotNull(rootComposeView) + + exporter.export(root, rootComposeView) + + assertNotNull(locateNodeByTag(root, "sentryTag")) + assertNotNull(locateNodeByTag(root, "testTag")) + } + } + + private fun locateAndroidComposeView(root: View?): Any? { + if (root == null) { + return null + } + if (root.javaClass.name == "androidx.compose.ui.platform.AndroidComposeView") { + return root + } + if (root is ViewGroup) { + for (child in root.children) { + val found = locateAndroidComposeView(child) + if (found != null) { + return found + } + } + } + return null + } + + private fun locateNodeByTag(root: ViewHierarchyNode, tag: String): ViewHierarchyNode? { + if (root.tag == tag) { + return root + } + + val children = root.children + if (children != null) { + for (child in children) { + val found = locateNodeByTag(child, tag) + if (found != null) { + return found + } + } + } + + return null + } +} diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt index 38aa2585d3f..f4c8dc545c5 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryModifierComposeTest.kt @@ -6,7 +6,7 @@ import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.ui.Modifier import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.compose.SentryModifier.sentryTag @@ -43,7 +43,7 @@ class SentryModifierComposeTest { } @get:Rule(order = 2) - val rule = createComposeRule() + val rule = createAndroidComposeRule() @Test fun sentryModifierAppliesTag() { diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.kt new file mode 100644 index 00000000000..dff518d2d93 --- /dev/null +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporterTest.kt @@ -0,0 +1,113 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.compose.viewhierarchy + +import androidx.compose.runtime.collection.mutableVectorOf +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.ModifierInfo +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsConfiguration +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import io.sentry.NoOpLogger +import io.sentry.internal.viewhierarchy.ViewHierarchyExporter +import io.sentry.protocol.ViewHierarchyNode +import org.junit.Assert +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ComposeViewHierarchyExporterTest { + @Test + fun testComposeViewHierarchyExport() { + val rootVhNode = ViewHierarchyNode() + + val childA = mockLayoutNode(true, "childA", 10, 20) + val childB = mockLayoutNode(true, null, 10, 20) + val childC = mockLayoutNode(false, null, 10, 20) + val parent = mockLayoutNode(true, "root", 30, 40, listOf(childA, childB, childC)) + + val node = mock() + whenever(node.root).thenReturn(parent) + + val exporter: ViewHierarchyExporter = + ComposeViewHierarchyExporter(NoOpLogger.getInstance()) + exporter.export(rootVhNode, node) + + Assert.assertEquals(1, rootVhNode.children!!.size.toLong()) + val parentVhNode = rootVhNode.children!![0] + + Assert.assertEquals("root", parentVhNode.tag) + Assert.assertEquals(30.0, parentVhNode.width!!, 0.001) + Assert.assertEquals(40.0, parentVhNode.height!!, 0.001) + + // ensure not placed elements (childC) are not part of the view hierarchy + Assert.assertEquals(2, parentVhNode.children!!.size.toLong()) + + val childAVhNode = parentVhNode.children!![0] + Assert.assertEquals("childA", childAVhNode.tag) + Assert.assertEquals(10.0, childAVhNode.width!!, 0.001) + Assert.assertEquals(20.0, childAVhNode.height!!, 0.001) + Assert.assertNull(childAVhNode.children) + + val childBVhNode = parentVhNode.children!![1] + Assert.assertNull(childBVhNode.tag) + } + + companion object { + private fun mockLayoutNode( + isPlaced: Boolean, + tag: String?, + width: Int, + height: Int, + children: List = emptyList() + ): LayoutNode { + val nodeA = Mockito.mock( + LayoutNode::class.java + ) + whenever(nodeA.isPlaced).thenReturn(isPlaced) + + val modifierInfo = Mockito.mock( + ModifierInfo::class.java + ) + whenever(modifierInfo.modifier) + .thenReturn( + object : SemanticsModifier { + override val semanticsConfiguration: SemanticsConfiguration + get() { + val config = SemanticsConfiguration() + config.set( + SemanticsPropertyKey( + "SentryTag" + ) { s: String?, s2: String? -> s }, + tag + ) + return config + } + } + ) + val modifierInfoList: MutableList = ArrayList() + modifierInfoList.add(modifierInfo) + whenever((nodeA.getModifierInfo())).thenReturn(modifierInfoList) + + whenever((nodeA.zSortedChildren)) + .thenReturn(mutableVectorOf().apply { addAll(children) }) + + val coordinates = Mockito.mock( + LayoutCoordinates::class.java + ) + val parentCoordinates = Mockito.mock( + LayoutCoordinates::class.java + ) + whenever(coordinates.parentLayoutCoordinates).thenReturn(parentCoordinates) + whenever(parentCoordinates.localBoundingBoxOf(any(), any())) + .thenReturn(Rect(0f, 0f, width.toFloat(), height.toFloat())) + whenever(nodeA.coordinates).thenReturn(coordinates) + return nodeA + } + } +} diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 04772af8aa0..cba0dfd2d77 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -126,7 +126,6 @@ dependencies { implementation(projects.sentryAndroidFragment) implementation(projects.sentryAndroidTimber) implementation(projects.sentryCompose) - implementation(projects.sentryComposeHelper) implementation(projects.sentryOkhttp) implementation(Config.Libs.fragment) implementation(Config.Libs.timber) diff --git a/settings.gradle.kts b/settings.gradle.kts index 4c642f1abd6..44cbd8720ca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,7 +22,6 @@ include( "sentry-android-sqlite", "sentry-android-replay", "sentry-compose", - "sentry-compose-helper", "sentry-apollo", "sentry-apollo-3", "sentry-apollo-4", From 70afa2e19ebef09a12f80a47d44f7640e620294e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 09:21:36 +0200 Subject: [PATCH 002/846] Bump reactivecircus/android-emulator-runner from 2.33.0 to 2.34.0 (#4301) Bumps [reactivecircus/android-emulator-runner](https://github.com/reactivecircus/android-emulator-runner) from 2.33.0 to 2.34.0. - [Release notes](https://github.com/reactivecircus/android-emulator-runner/releases) - [Changelog](https://github.com/ReactiveCircus/android-emulator-runner/blob/main/CHANGELOG.md) - [Commits](https://github.com/reactivecircus/android-emulator-runner/compare/62dbb605bba737720e10b196cb4220d374026a6d...1dcd0090116d15e7c562f8db72807de5e036a4ed) --- updated-dependencies: - dependency-name: reactivecircus/android-emulator-runner 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/agp-matrix.yml | 2 +- .github/workflows/integration-tests-ui-critical.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 32982b72491..16b76eded37 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -61,7 +61,7 @@ jobs: # We tried to use the cache action to cache gradle stuff, but it made tests slower and timeout - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2 + uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # pin@v2 with: api-level: 30 force-avd-creation: false diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index c7a90838bbd..6f6041995fe 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -102,7 +102,7 @@ jobs: version: ${{env.MAESTRO_VERSION}} - name: Run tests - uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2.33.0 + uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # pin@v2.34.0 with: api-level: ${{ matrix.api-level }} force-avd-creation: false From a1ad6aef851fea867e9f97d4a922981b6bfedeb1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 07:45:42 +0000 Subject: [PATCH 003/846] Bump github/codeql-action from 3.28.12 to 3.28.13 (#4300) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.12 to 3.28.13. - [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/5f8171a638ada777af81d42b55959a643bb29017...1b549b9259bda1cb5ddde3b41741a82a2d15a841) --- 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 c1ac14a94c4..d7adce97373 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@5f8171a638ada777af81d42b55959a643bb29017 # pin@v2 + uses: github/codeql-action/init@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # pin@v2 with: languages: 'java' @@ -49,4 +49,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5f8171a638ada777af81d42b55959a643bb29017 # pin@v2 + uses: github/codeql-action/analyze@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # pin@v2 From 008761b8ca6fb53f0a437ddde9be75f328f54864 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 10:55:37 +0200 Subject: [PATCH 004/846] Bump gradle/actions (#4299) Bumps [gradle/actions](https://github.com/gradle/actions) from 4a417b5b1a01db0b076987546b67f8de18e7d340 to 06832c7b30a0129d7fb559bcc6e43d26f6374244. - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/4a417b5b1a01db0b076987546b67f8de18e7d340...06832c7b30a0129d7fb559bcc6e43d26f6374244) --- 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 16b76eded37..c0bf73a3451 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # 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 5d3d03e1c76..426e584c0b4 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # 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 d7adce97373..ce3d81bcc90 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # 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 88ffbfb64cc..47b7923b03b 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index fe5f0dad23c..ff15158d88a 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # 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 6160282ad56..3b24ac16fb8 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # 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 6f6041995fe..76f2c77f31c 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # 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 0bb09140398..fcd7ad80097 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # 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 164b7380a18..46a7cf0de54 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # 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 65cbd5fb4cf..2e491878e78 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@4a417b5b1a01db0b076987546b67f8de18e7d340 # pin@v3 + uses: gradle/actions/setup-gradle@06832c7b30a0129d7fb559bcc6e43d26f6374244 # pin@v3 with: gradle-home-cache-cleanup: true cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} From 476217063fa59b3941449c22ec4091a0669b5a8e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:58:40 +0000 Subject: [PATCH 005/846] Bump actions/create-github-app-token from 1.11.7 to 1.12.0 (#4302) Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 1.11.7 to 1.12.0. - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Commits](https://github.com/actions/create-github-app-token/compare/af35edadc00be37caa72ed9f3e6d5f7801bfdf09...d72941d797fd3113feb6b93fd0dec494b13a2547) --- updated-dependencies: - dependency-name: actions/create-github-app-token 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/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f97335b62c1..fe4dc5b3810 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@af35edadc00be37caa72ed9f3e6d5f7801bfdf09 # v1.11.7 + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From de2136e055052b81ed59a3e3455e446a0f303018 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 31 Mar 2025 12:17:12 +0200 Subject: [PATCH 006/846] fix(file-io): Do not leak SentryFileInputStream/SentryFileOutputStream descriptors and channels (#4296) * fix(file-io): Do not leak SentryFileInputStream/SentryFileOutputStream descriptors and channels * Revert * Changelog * Update sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java Co-authored-by: Stefano --------- Co-authored-by: Stefano --- CHANGELOG.md | 1 + .../sentry/samples/android/MainActivity.java | 19 +++++-------------- .../file/SentryFileInputStream.java | 1 + .../file/SentryFileOutputStream.java | 1 + .../file/SentryFileInputStreamTest.kt | 12 ++++++++++++ .../file/SentryFileOutputStreamTest.kt | 12 ++++++++++++ 6 files changed, 32 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d136fd9c7a..b027fd7834b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - An example value would be `8.6.0` - The value of the `Sentry-Version-Name` attribute looks like `sentry-8.5.0-otel-2.10.0` - Fix tags missing for compose view hierarchies ([#4275](https://github.com/getsentry/sentry-java/pull/4275)) +- Do not leak SentryFileInputStream/SentryFileOutputStream descriptors and channels ([#4296](https://github.com/getsentry/sentry-java/pull/4296)) ### Internal 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 e881612bd80..a4085bf8225 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 @@ -14,18 +14,15 @@ import io.sentry.protocol.User; import io.sentry.samples.android.compose.ComposeActivity; import io.sentry.samples.android.databinding.ActivityMainBinding; -import java.io.BufferedWriter; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStreamWriter; -import java.io.Writer; +import java.nio.channels.FileChannel; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.List; -import java.util.Locale; import java.util.concurrent.CountDownLatch; import timber.log.Timber; @@ -86,16 +83,10 @@ protected void onCreate(Bundle savedInstanceState) { view -> { String fileName = Calendar.getInstance().getTimeInMillis() + "_file.txt"; File file = getApplication().getFileStreamPath(fileName); - try (final FileOutputStream fileOutputStream = new SentryFileOutputStream(file); - final OutputStreamWriter outputStreamWriter = - new OutputStreamWriter(fileOutputStream); - final Writer writer = new BufferedWriter(outputStreamWriter)) { - for (int i = 0; i < 1024; i++) { - // To keep the sample code simple this happens on the main thread. Don't do this in a - // real app. - writer.write(String.format(Locale.getDefault(), "%d\n", i)); - } - writer.flush(); + try (final FileOutputStream fos = + SentryFileOutputStream.Factory.create(new FileOutputStream(file), file)) { + FileChannel channel = fos.getChannel(); + channel.write(java.nio.ByteBuffer.wrap("Hello, World!".getBytes())); } catch (IOException e) { Sentry.captureException(e); } diff --git a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java index 0ee5df6d799..3119b50b5ed 100644 --- a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java +++ b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileInputStream.java @@ -113,6 +113,7 @@ public long skip(final long n) throws IOException { @Override public void close() throws IOException { spanManager.finish(delegate); + super.close(); } private static FileDescriptor getFileDescriptor(final @NotNull FileInputStream stream) diff --git a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java index 483d6e97819..76f4cd68cba 100644 --- a/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java +++ b/sentry/src/main/java/io/sentry/instrumentation/file/SentryFileOutputStream.java @@ -120,6 +120,7 @@ public void write(final byte @NotNull [] b, final int off, final int len) throws @Override public void close() throws IOException { spanManager.finish(delegate); + super.close(); } private static FileDescriptor getFileDescriptor(final @NotNull FileOutputStream stream) diff --git a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt index db52296471f..e0b9316a239 100644 --- a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt +++ b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileInputStreamTest.kt @@ -18,6 +18,7 @@ import java.io.File import java.io.FileDescriptor import java.io.FileInputStream import java.io.IOException +import java.nio.ByteBuffer import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.thread import kotlin.test.Test @@ -285,6 +286,17 @@ class SentryFileInputStreamTest { assertTrue { stream is ThrowingFileInputStream } } + + @Test + fun `channels and descriptors are closed together with the stream`() { + val fis = fixture.getSut(tmpFile) + val channel = fis.channel + + channel.read(ByteBuffer.allocate(1)) + fis.close() + assertFalse(channel.isOpen) + assertFalse(fis.fd.valid()) + } } class ThrowingFileInputStream(file: File) : FileInputStream(file) { diff --git a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt index df66a3b6c30..ed7edb5785e 100644 --- a/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt +++ b/sentry/src/test/java/io/sentry/instrumentation/file/SentryFileOutputStreamTest.kt @@ -16,6 +16,7 @@ import org.mockito.kotlin.whenever import java.io.File import java.io.FileOutputStream import java.io.IOException +import java.nio.ByteBuffer import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.thread import kotlin.test.Test @@ -231,6 +232,17 @@ class SentryFileOutputStreamTest { assertTrue { stream is ThrowingFileOutputStream } } + + @Test + fun `channels and descriptors are closed together with the stream`() { + val fos = fixture.getSut(tmpFile) + val channel = fos.channel + + channel.write(ByteBuffer.wrap("hello".toByteArray())) + fos.close() + assertFalse(channel.isOpen) + assertFalse(fos.fd.valid()) + } } class ThrowingFileOutputStream(file: File) : FileOutputStream(file) { From 708d339f63838c40f12fdadb5bf84c033fe4e0e4 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 1 Apr 2025 06:30:28 +0200 Subject: [PATCH 007/846] Remove "not yet implemented" from flush comments (#4305) * Remove not yet implemented from flush comments * changelog * move changelog entry --- CHANGELOG.md | 1 + sentry/src/main/java/io/sentry/IScopes.java | 2 +- sentry/src/main/java/io/sentry/ISentryClient.java | 2 +- sentry/src/main/java/io/sentry/Sentry.java | 2 +- sentry/src/main/java/io/sentry/transport/ITransport.java | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b027fd7834b..c3c79ad7d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - The value of the `Sentry-Version-Name` attribute looks like `sentry-8.5.0-otel-2.10.0` - Fix tags missing for compose view hierarchies ([#4275](https://github.com/getsentry/sentry-java/pull/4275)) - Do not leak SentryFileInputStream/SentryFileOutputStream descriptors and channels ([#4296](https://github.com/getsentry/sentry-java/pull/4296)) +- Remove "not yet implemented" from `Sentry.flush` comment ([#4305](https://github.com/getsentry/sentry-java/pull/4305)) ### Internal diff --git a/sentry/src/main/java/io/sentry/IScopes.java b/sentry/src/main/java/io/sentry/IScopes.java index 42b434ca2db..40884f63947 100644 --- a/sentry/src/main/java/io/sentry/IScopes.java +++ b/sentry/src/main/java/io/sentry/IScopes.java @@ -376,7 +376,7 @@ default void configureScope(@NotNull ScopeCallback callback) { boolean isHealthy(); /** - * Flushes events queued up, but keeps the scopes enabled. Not implemented yet. + * Flushes events queued up, but keeps the scopes enabled. * * @param timeoutMillis time in milliseconds */ diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index 22389f2b6e5..198f77f2f0d 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -40,7 +40,7 @@ public interface ISentryClient { void close(boolean isRestarting); /** - * Flushes events queued up, but keeps the client enabled. Not implemented yet. + * Flushes events queued up, but keeps the client enabled. * * @param timeoutMillis time in milliseconds */ diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index dc9de2a203d..70d4c7b380e 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1030,7 +1030,7 @@ public static boolean isHealthy() { } /** - * Flushes events queued up to the current Scopes. Not implemented yet. + * Flushes events queued up to the current Scopes. * * @param timeoutMillis time in milliseconds */ diff --git a/sentry/src/main/java/io/sentry/transport/ITransport.java b/sentry/src/main/java/io/sentry/transport/ITransport.java index ccc3db4a0cb..ec9ae43f998 100644 --- a/sentry/src/main/java/io/sentry/transport/ITransport.java +++ b/sentry/src/main/java/io/sentry/transport/ITransport.java @@ -20,7 +20,7 @@ default boolean isHealthy() { } /** - * Flushes events queued up, but keeps the client enabled. Not implemented yet. + * Flushes events queued up, but keeps the client enabled. * * @param timeoutMillis time in milliseconds */ From 0d0e1d3b2bd5a93cd08e8826167a0b3001145f61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Apr 2025 08:04:31 +0000 Subject: [PATCH 008/846] chore(deps): update Native SDK to v0.8.3 (#4298) * chore: update scripts/update-sentry-native-ndk.sh to 0.8.3 * chore: update scripts/update-sentry-native-ndk.sh to 0.8.3 * Add glue code and additional tests --------- Co-authored-by: GitHub Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 6 +- buildSrc/src/main/java/Config.kt | 2 +- .../java/io/sentry/android/ndk/SentryNdk.java | 8 +++ .../io/sentry/android/ndk/SentryNdkTest.kt | 72 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c79ad7d7c..b6ae4e51807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,9 +38,9 @@ ### Dependencies -- Bump Native SDK from v0.8.1 to v0.8.2 ([#4267](https://github.com/getsentry/sentry-java/pull/4267)) - - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#082) - - [diff](https://github.com/getsentry/sentry-native/compare/0.8.1...0.8.2) +- Bump Native SDK from v0.8.1 to v0.8.3 ([#4267](https://github.com/getsentry/sentry-java/pull/4267), [#4298](https://github.com/getsentry/sentry-java/pull/4298)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#083) + - [diff](https://github.com/getsentry/sentry-native/compare/0.8.1...0.8.3) - Bump Spring Boot from 2.7.5 to 2.7.18 ([#3496](https://github.com/getsentry/sentry-java/pull/3496)) ## 8.5.0 diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 4defb637b26..d5f2e5029df 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -161,7 +161,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.8.2" + val sentryNativeNdk = "io.sentry:sentry-native-ndk:0.8.3" object OpenTelemetry { val otelVersion = "1.44.1" diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java index cce8e35b82e..9d6d64a1236 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -9,6 +9,7 @@ import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @ApiStatus.Internal public final class SentryNdk { @@ -65,6 +66,13 @@ public static void init(@NotNull final SentryAndroidOptions options) { io.sentry.ndk.NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_CHAIN_AT_START); } + final @Nullable Double tracesSampleRate = options.getTracesSampleRate(); + if (tracesSampleRate == null) { + ndkOptions.setTracesSampleRate(0.0f); + } else { + ndkOptions.setTracesSampleRate(tracesSampleRate.floatValue()); + } + //noinspection UnstableApiUsage io.sentry.ndk.SentryNdk.init(ndkOptions); diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt new file mode 100644 index 00000000000..5cc6c613b4e --- /dev/null +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt @@ -0,0 +1,72 @@ +package io.sentry.android.ndk + +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.ndk.NdkOptions +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@Suppress("UnstableApiUsage") +class SentryNdkTest { + + class Fixture { + + var capturedOptions: NdkOptions? = null + + fun getSut( + options: SentryAndroidOptions = SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = "/cache" + }, + closure: () -> Unit + ) { + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { utils -> + utils.`when` { io.sentry.ndk.SentryNdk.init(any()) }.doAnswer { + capturedOptions = it.arguments[0] as NdkOptions + } + SentryNdk.init(options) + closure.invoke() + } + } + } + + val fixture = Fixture() + + @Test + fun `SentryNdk calls NDK init`() { + fixture.getSut() { + assertNotNull(fixture.capturedOptions) + } + } + + @Test + fun `SentryNdk propagates null tracesSampleRate`() { + fixture.getSut( + options = SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = "/cache" + tracesSampleRate = null + } + ) { + assertNotNull(fixture.capturedOptions) + assertEquals(0.0f, fixture.capturedOptions!!.tracesSampleRate, 0.0001f) + } + } + + @Test + fun `SentryNdk propagates non-null tracesSampleRate`() { + fixture.getSut( + options = SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = "/cache" + tracesSampleRate = 0.75 + } + ) { + assertNotNull(fixture.capturedOptions) + assertEquals(0.75f, fixture.capturedOptions!!.tracesSampleRate, 0.0001f) + } + } +} From 8875a00d5097d398f9e3b69ca206350f707c4f4e Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 1 Apr 2025 08:24:35 +0000 Subject: [PATCH 009/846] release: 8.6.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ae4e51807..64bb9941951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.6.0 ### Behavioral Changes diff --git a/gradle.properties b/gradle.properties index 7c64ccc3742..58de88ec42b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ org.gradle.workers.max=2 android.useAndroidX=true # Release information -versionName=8.5.0 +versionName=8.6.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From f44cfb799cabd0a5cb7085c5a79e244921b3e164 Mon Sep 17 00:00:00 2001 From: Stefano Date: Fri, 4 Apr 2025 12:15:04 +0200 Subject: [PATCH 010/846] Continuous Profiling - Out of Experimental (#4310) * moved continuous profiling out of ExperimentalOptions back into SentryOptions --- CHANGELOG.md | 6 ++ .../android/core/ManifestMetadataReader.java | 22 +++--- .../core/SentryPerformanceProvider.java | 5 +- .../core/ManifestMetadataReaderTest.kt | 2 +- sentry/api/sentry.api | 9 +-- .../java/io/sentry/ExperimentalOptions.java | 72 ------------------- .../main/java/io/sentry/SentryOptions.java | 72 ++++++++++++++++--- sentry/src/test/java/io/sentry/ScopeTest.kt | 6 +- sentry/src/test/java/io/sentry/ScopesTest.kt | 30 ++++---- .../test/java/io/sentry/SentryOptionsTest.kt | 18 ++--- sentry/src/test/java/io/sentry/SentryTest.kt | 18 ++--- .../test/java/io/sentry/SentryTracerTest.kt | 2 +- .../test/java/io/sentry/TracesSamplerTest.kt | 2 +- 13 files changed, 121 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64bb9941951..edb3fd60346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Continuous Profiling - Out of Experimental ([#4310](https://github.com/getsentry/sentry-java/pull/4310)) + ## 8.6.0 ### Behavioral Changes 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 e1f97aa78be..4836c044c0c 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 @@ -335,7 +335,7 @@ static void applyMetadata( final double profileSessionSampleRate = readDouble(metadata, logger, PROFILE_SESSION_SAMPLE_RATE); if (profileSessionSampleRate != -1) { - options.getExperimental().setProfileSessionSampleRate(profileSessionSampleRate); + options.setProfileSessionSampleRate(profileSessionSampleRate); } } @@ -346,20 +346,16 @@ static void applyMetadata( PROFILE_LIFECYCLE, options.getProfileLifecycle().name().toLowerCase(Locale.ROOT)); if (profileLifecycle != null) { - options - .getExperimental() - .setProfileLifecycle( - ProfileLifecycle.valueOf(profileLifecycle.toUpperCase(Locale.ROOT))); + options.setProfileLifecycle( + ProfileLifecycle.valueOf(profileLifecycle.toUpperCase(Locale.ROOT))); } - options - .getExperimental() - .setStartProfilerOnAppStart( - readBool( - metadata, - logger, - PROFILER_START_ON_APP_START, - options.isStartProfilerOnAppStart())); + options.setStartProfilerOnAppStart( + readBool( + metadata, + logger, + PROFILER_START_ON_APP_START, + options.isStartProfilerOnAppStart())); options.setEnableUserInteractionTracing( readBool(metadata, logger, TRACES_UI_ENABLE, options.isEnableUserInteractionTracing())); 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 b5d93eb0a50..4b569cba6a8 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 @@ -175,9 +175,8 @@ private void createAndStartContinuousProfiler( logger.log(SentryLevel.DEBUG, "App start continuous profiling started."); SentryOptions sentryOptions = SentryOptions.empty(); // Let's fake a sampler to accept the sampling decision that was calculated on last run - sentryOptions - .getExperimental() - .setProfileSessionSampleRate(profilingOptions.isContinuousProfileSampled() ? 1.0 : 0.0); + sentryOptions.setProfileSessionSampleRate( + profilingOptions.isContinuousProfileSampled() ? 1.0 : 0.0); appStartContinuousProfiler.startProfiler( profilingOptions.getProfileLifecycle(), new TracesSampler(sentryOptions)); } 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 fcfb4bf814b..781eef7ac93 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 @@ -826,7 +826,7 @@ class ManifestMetadataReaderTest { fun `applyMetadata does not override profileSessionSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - fixture.options.experimental.profileSessionSampleRate = expectedSampleRate.toDouble() + fixture.options.profileSessionSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.PROFILE_SESSION_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 796284690d8..0aafb1181cb 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -458,12 +458,6 @@ public abstract interface class io/sentry/EventProcessor { public final class io/sentry/ExperimentalOptions { public fun (ZLio/sentry/protocol/SdkVersion;)V - public fun getProfileLifecycle ()Lio/sentry/ProfileLifecycle; - public fun getProfileSessionSampleRate ()Ljava/lang/Double; - public fun isStartProfilerOnAppStart ()Z - public fun setProfileLifecycle (Lio/sentry/ProfileLifecycle;)V - public fun setProfileSessionSampleRate (Ljava/lang/Double;)V - public fun setStartProfilerOnAppStart (Z)V } public final class io/sentry/ExternalOptions { @@ -3222,6 +3216,8 @@ public class io/sentry/SentryOptions { public fun setModulesLoader (Lio/sentry/internal/modules/IModulesLoader;)V public fun setOpenTelemetryMode (Lio/sentry/SentryOpenTelemetryMode;)V public fun setPrintUncaughtStackTrace (Z)V + public fun setProfileLifecycle (Lio/sentry/ProfileLifecycle;)V + public fun setProfileSessionSampleRate (Ljava/lang/Double;)V public fun setProfilesSampleRate (Ljava/lang/Double;)V public fun setProfilesSampler (Lio/sentry/SentryOptions$ProfilesSamplerCallback;)V public fun setProfilingTracesHz (I)V @@ -3245,6 +3241,7 @@ public class io/sentry/SentryOptions { public fun setSpanFactory (Lio/sentry/ISpanFactory;)V public fun setSpotlightConnectionUrl (Ljava/lang/String;)V public fun setSslSocketFactory (Ljavax/net/ssl/SSLSocketFactory;)V + public fun setStartProfilerOnAppStart (Z)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V public fun setThreadChecker (Lio/sentry/util/thread/IThreadChecker;)V public fun setTraceOptionsRequests (Z)V diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index 4e1c681a483..80d59d4f01b 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -1,9 +1,6 @@ package io.sentry; import io.sentry.protocol.SdkVersion; -import io.sentry.util.SampleRateUtils; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** @@ -14,74 +11,5 @@ */ public final class ExperimentalOptions { - /** - * Indicates the percentage in which the profiles for the session will be created. Specifying 0 - * means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0 The default is null - * (disabled). - */ - private @Nullable Double profileSessionSampleRate; - - /** - * Whether the profiling lifecycle is controlled manually or based on the trace lifecycle. - * Defaults to {@link ProfileLifecycle#MANUAL}. - */ - private @NotNull ProfileLifecycle profileLifecycle = ProfileLifecycle.MANUAL; - - /** - * Whether profiling can automatically be started as early as possible during the app lifecycle, - * to capture more of app startup. If {@link ExperimentalOptions#profileLifecycle} is {@link - * ProfileLifecycle#MANUAL} Profiling is started automatically on startup and stopProfiler must be - * called manually whenever the app startup is completed If {@link - * ExperimentalOptions#profileLifecycle} is {@link ProfileLifecycle#TRACE} Profiling is started - * automatically on startup, and will automatically be stopped when the root span that is - * associated with app startup ends - */ - private boolean startProfilerOnAppStart = false; - public ExperimentalOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) {} - - /** - * Returns whether the profiling cycle is controlled manually or based on the trace lifecycle. - * Defaults to {@link ProfileLifecycle#MANUAL}. - * - * @return the profile lifecycle - */ - @ApiStatus.Experimental - public @NotNull ProfileLifecycle getProfileLifecycle() { - return profileLifecycle; - } - - /** Sets the profiling lifecycle. */ - @ApiStatus.Experimental - public void setProfileLifecycle(final @NotNull ProfileLifecycle profileLifecycle) { - // TODO (when moved to SentryOptions): we should log a message if the user sets this to TRACE - // and tracing is disabled - this.profileLifecycle = profileLifecycle; - } - - @ApiStatus.Experimental - public @Nullable Double getProfileSessionSampleRate() { - return profileSessionSampleRate; - } - - @ApiStatus.Experimental - public void setProfileSessionSampleRate(final @Nullable Double profileSessionSampleRate) { - if (!SampleRateUtils.isValidContinuousProfilesSampleRate(profileSessionSampleRate)) { - throw new IllegalArgumentException( - "The value " - + profileSessionSampleRate - + " is not valid. Use values between 0.0 and 1.0."); - } - this.profileSessionSampleRate = profileSessionSampleRate; - } - - @ApiStatus.Experimental - public boolean isStartProfilerOnAppStart() { - return startProfilerOnAppStart; - } - - @ApiStatus.Experimental - public void setStartProfilerOnAppStart(boolean startProfilerOnAppStart) { - this.startProfilerOnAppStart = startProfilerOnAppStart; - } } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 9f55d24f740..d5623d44f26 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -542,6 +542,30 @@ public class SentryOptions { @ApiStatus.Experimental private boolean captureOpenTelemetryEvents = false; private @NotNull IVersionDetector versionDetector = NoopVersionDetector.getInstance(); + + /** + * Indicates the percentage in which the profiles for the session will be created. Specifying 0 + * means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0 The default is null + * (disabled). + */ + private @Nullable Double profileSessionSampleRate; + + /** + * Whether the profiling lifecycle is controlled manually or based on the trace lifecycle. + * Defaults to {@link ProfileLifecycle#MANUAL}. + */ + private @NotNull ProfileLifecycle profileLifecycle = ProfileLifecycle.MANUAL; + + /** + * Whether profiling can automatically be started as early as possible during the app lifecycle, + * to capture more of app startup. If {@link SentryOptions#profileLifecycle} is {@link + * ProfileLifecycle#MANUAL} Profiling is started automatically on startup and stopProfiler must be + * called manually whenever the app startup is completed If {@link SentryOptions#profileLifecycle} + * is {@link ProfileLifecycle#TRACE} Profiling is started automatically on startup, and will + * automatically be stopped when the root span that is associated with app startup ends + */ + private boolean startProfilerOnAppStart = false; + /** * Adds an event processor * @@ -1821,7 +1845,6 @@ public void setTransactionProfiler(final @Nullable ITransactionProfiler transact * * @return the continuous profiler. */ - @ApiStatus.Experimental public @NotNull IContinuousProfiler getContinuousProfiler() { return continuousProfiler; } @@ -1831,7 +1854,6 @@ public void setTransactionProfiler(final @Nullable ITransactionProfiler transact * * @param continuousProfiler - the continuous profiler */ - @ApiStatus.Experimental public void setContinuousProfiler(final @Nullable IContinuousProfiler continuousProfiler) { // We allow to set the profiler only if it was not set before, and we don't allow to unset it. if (this.continuousProfiler == NoOpContinuousProfiler.getInstance() @@ -1859,8 +1881,8 @@ public boolean isProfilingEnabled() { public boolean isContinuousProfilingEnabled() { return profilesSampleRate == null && profilesSampler == null - && experimental.getProfileSessionSampleRate() != null - && experimental.getProfileSessionSampleRate() > 0; + && profileSessionSampleRate != null + && profileSessionSampleRate > 0; } /** @@ -1914,9 +1936,23 @@ public void setProfilesSampleRate(final @Nullable Double profilesSampleRate) { * * @return the sample rate */ - @ApiStatus.Experimental public @Nullable Double getProfileSessionSampleRate() { - return experimental.getProfileSessionSampleRate(); + return profileSessionSampleRate; + } + + /** + * Set the session sample rate. Default is null (disabled). ProfilesSampleRate takes precedence + * over this. To enable continuous profiling, don't set profilesSampleRate or profilesSampler, or + * set them to null. + */ + public void setProfileSessionSampleRate(final @Nullable Double profileSessionSampleRate) { + if (!SampleRateUtils.isValidContinuousProfilesSampleRate(profileSessionSampleRate)) { + throw new IllegalArgumentException( + "The value " + + profileSessionSampleRate + + " is not valid. Use values between 0.0 and 1.0."); + } + this.profileSessionSampleRate = profileSessionSampleRate; } /** @@ -1925,17 +1961,33 @@ public void setProfilesSampleRate(final @Nullable Double profilesSampleRate) { * * @return the profile lifecycle */ - @ApiStatus.Experimental public @NotNull ProfileLifecycle getProfileLifecycle() { - return experimental.getProfileLifecycle(); + return profileLifecycle; + } + + /** Sets the profiling lifecycle. */ + public void setProfileLifecycle(final @NotNull ProfileLifecycle profileLifecycle) { + this.profileLifecycle = profileLifecycle; + if (profileLifecycle == ProfileLifecycle.TRACE && !isTracingEnabled()) { + logger.log( + SentryLevel.WARNING, + "Profiling lifecycle is set to TRACE but tracing is disabled. " + + "Profiling will not be started automatically."); + } } /** * Whether profiling can automatically be started as early as possible during the app lifecycle. */ - @ApiStatus.Experimental public boolean isStartProfilerOnAppStart() { - return experimental.isStartProfilerOnAppStart(); + return startProfilerOnAppStart; + } + + /** + * Set if profiling can automatically be started as early as possible during the app lifecycle. + */ + public void setStartProfilerOnAppStart(final boolean startProfilerOnAppStart) { + this.startProfilerOnAppStart = startProfilerOnAppStart; } /** diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 5645d582a36..8ebde805487 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -398,7 +398,7 @@ class ScopeTest { val options = SentryOptions().apply { release = "0.0.1" setContinuousProfiler(profiler) - experimental.profileSessionSampleRate = 1.0 + profileSessionSampleRate = 1.0 } val scope = Scope(options) @@ -419,7 +419,7 @@ class ScopeTest { val options = SentryOptions().apply { release = "0.0.1" setContinuousProfiler(profiler) - experimental.profileSessionSampleRate = 1.0 + profileSessionSampleRate = 1.0 } val scope = Scope(options) @@ -435,7 +435,7 @@ class ScopeTest { val options = SentryOptions().apply { release = "0.0.1" setContinuousProfiler(profiler) - experimental.profileSessionSampleRate = 1.0 + profileSessionSampleRate = 1.0 } val scope = Scope(options) diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index a26273ab3b0..d61ae59f60c 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -1818,7 +1818,7 @@ class ScopesTest { setTransactionProfiler(profiler) compositePerformanceCollector = performanceCollector setContinuousProfiler(continuousProfiler) - experimental.profileSessionSampleRate = 1.0 + profileSessionSampleRate = 1.0 backpressureMonitor = backpressureMonitorMock } val sut = createScopes(options) @@ -1892,8 +1892,8 @@ class ScopesTest { val scopes = generateScopes { it.tracesSampleRate = 1.0 it.setContinuousProfiler(mockProfiler) - it.experimental.profileSessionSampleRate = 1.0 - it.experimental.profileLifecycle = ProfileLifecycle.TRACE + it.profileSessionSampleRate = 1.0 + it.profileLifecycle = ProfileLifecycle.TRACE } val transaction = scopes.startTransaction("name", "op") @@ -1906,8 +1906,8 @@ class ScopesTest { val scopes = generateScopes { it.tracesSampleRate = 1.0 it.setContinuousProfiler(mockProfiler) - it.experimental.profileSessionSampleRate = 1.0 - it.experimental.profileLifecycle = ProfileLifecycle.MANUAL + it.profileSessionSampleRate = 1.0 + it.profileLifecycle = ProfileLifecycle.MANUAL } val transaction = scopes.startTransaction("name", "op") @@ -1921,8 +1921,8 @@ class ScopesTest { // If transaction is not sampled, profiler should not start it.tracesSampleRate = 0.0 it.setContinuousProfiler(mockProfiler) - it.experimental.profileSessionSampleRate = 1.0 - it.experimental.profileLifecycle = ProfileLifecycle.TRACE + it.profileSessionSampleRate = 1.0 + it.profileLifecycle = ProfileLifecycle.TRACE } val transaction = scopes.startTransaction("name", "op") transaction.spanContext.setSampled(false, false) @@ -2244,7 +2244,7 @@ class ScopesTest { val profiler = mock() val scopes = generateScopes { it.setContinuousProfiler(profiler) - it.experimental.profileSessionSampleRate = 1.0 + it.profileSessionSampleRate = 1.0 } scopes.startProfiler() verify(profiler).startProfiler(eq(ProfileLifecycle.MANUAL), any()) @@ -2256,7 +2256,7 @@ class ScopesTest { val logger = mock() val scopes = generateScopes { it.setContinuousProfiler(profiler) - it.experimental.profileSessionSampleRate = 1.0 + it.profileSessionSampleRate = 1.0 it.profilesSampleRate = 1.0 it.setLogger(logger) it.isDebug = true @@ -2272,8 +2272,8 @@ class ScopesTest { val logger = mock() val scopes = generateScopes { it.setContinuousProfiler(profiler) - it.experimental.profileSessionSampleRate = 1.0 - it.experimental.profileLifecycle = ProfileLifecycle.TRACE + it.profileSessionSampleRate = 1.0 + it.profileLifecycle = ProfileLifecycle.TRACE it.setLogger(logger) it.isDebug = true } @@ -2287,7 +2287,7 @@ class ScopesTest { val profiler = mock() val scopes = generateScopes { it.setContinuousProfiler(profiler) - it.experimental.profileSessionSampleRate = 1.0 + it.profileSessionSampleRate = 1.0 } scopes.stopProfiler() verify(profiler).stopProfiler(eq(ProfileLifecycle.MANUAL)) @@ -2299,7 +2299,7 @@ class ScopesTest { val logger = mock() val scopes = generateScopes { it.setContinuousProfiler(profiler) - it.experimental.profileSessionSampleRate = 1.0 + it.profileSessionSampleRate = 1.0 it.profilesSampleRate = 1.0 it.setLogger(logger) it.isDebug = true @@ -2315,8 +2315,8 @@ class ScopesTest { val logger = mock() val scopes = generateScopes { it.setContinuousProfiler(profiler) - it.experimental.profileSessionSampleRate = 1.0 - it.experimental.profileLifecycle = ProfileLifecycle.TRACE + it.profileSessionSampleRate = 1.0 + it.profileLifecycle = ProfileLifecycle.TRACE it.setLogger(logger) it.isDebug = true } diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index e13d3273939..b777fe3af77 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -239,7 +239,7 @@ class SentryOptionsTest { @Test fun `when profileSessionSampleRate is set to 0, isProfilingEnabled is false and isContinuousProfilingEnabled is false`() { val options = SentryOptions().apply { - this.experimental.profileSessionSampleRate = 0.0 + this.profileSessionSampleRate = 0.0 } assertFalse(options.isProfilingEnabled) assertFalse(options.isContinuousProfilingEnabled) @@ -248,7 +248,7 @@ class SentryOptionsTest { @Test fun `when profileSessionSampleRate is null, isProfilingEnabled is false and isContinuousProfilingEnabled is false`() { val options = SentryOptions() - assertNull(options.experimental.profileSessionSampleRate) + assertNull(options.profileSessionSampleRate) assertFalse(options.isProfilingEnabled) assertFalse(options.isContinuousProfilingEnabled) } @@ -274,25 +274,25 @@ class SentryOptionsTest { @Test fun `when profileSessionSampleRate is set to exactly 0, value is set`() { val options = SentryOptions().apply { - this.experimental.profileSessionSampleRate = 0.0 + this.profileSessionSampleRate = 0.0 } assertEquals(0.0, options.profileSessionSampleRate) } @Test fun `when profileSessionSampleRate is set to higher than 1_0, setter throws`() { - assertFailsWith { SentryOptions().experimental.profileSessionSampleRate = 1.0000000000001 } + assertFailsWith { SentryOptions().profileSessionSampleRate = 1.0000000000001 } } @Test fun `when profileSessionSampleRate is set to lower than 0, setter throws`() { - assertFailsWith { SentryOptions().experimental.profileSessionSampleRate = -0.0000000000001 } + assertFailsWith { SentryOptions().profileSessionSampleRate = -0.0000000000001 } } @Test fun `when profileLifecycleSessionSampleRate is set to a value, value is set`() { val options = SentryOptions().apply { - this.experimental.profileLifecycle = ProfileLifecycle.TRACE + this.profileLifecycle = ProfileLifecycle.TRACE } assertEquals(ProfileLifecycle.TRACE, options.profileLifecycle) } @@ -306,7 +306,7 @@ class SentryOptionsTest { @Test fun `when isStartProfilerOnAppStart is set to a value, value is set`() { val options = SentryOptions().apply { - this.experimental.isStartProfilerOnAppStart = true + this.isStartProfilerOnAppStart = true } assertTrue(options.isStartProfilerOnAppStart) } @@ -643,7 +643,7 @@ class SentryOptionsTest { fun `when profiling is disabled, isEnableAppStartProfiling is always false`() { val options = SentryOptions() options.isEnableAppStartProfiling = true - options.experimental.profileSessionSampleRate = 0.0 + options.profileSessionSampleRate = 0.0 assertFalse(options.isEnableAppStartProfiling) } @@ -651,7 +651,7 @@ class SentryOptionsTest { fun `when setEnableAppStartProfiling is called and continuous profiling is enabled, isEnableAppStartProfiling is true`() { val options = SentryOptions() options.isEnableAppStartProfiling = true - options.experimental.profileSessionSampleRate = 1.0 + options.profileSessionSampleRate = 1.0 assertTrue(options.isEnableAppStartProfiling) } diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index f454b3707fe..5d510d20e4e 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -407,7 +407,7 @@ class SentryTest { var sentryOptions: SentryOptions? = null Sentry.init { it.dsn = dsn - it.experimental.profileSessionSampleRate = 1.0 + it.profileSessionSampleRate = 1.0 it.cacheDirPath = tempPath sentryOptions = it } @@ -422,7 +422,7 @@ class SentryTest { var sentryOptions: SentryOptions? = null Sentry.init { it.dsn = dsn - it.experimental.profileSessionSampleRate = 0.0 + it.profileSessionSampleRate = 0.0 it.cacheDirPath = tempPath sentryOptions = it } @@ -1138,7 +1138,7 @@ class SentryTest { Sentry.init { it.dsn = dsn it.tracesSampleRate = 1.0 - it.experimental.isStartProfilerOnAppStart = true + it.isStartProfilerOnAppStart = true it.profilesSampleRate = 1.0 it.tracesSampler = mockSampleTracer it.profilesSampler = mockProfilesSampler @@ -1251,7 +1251,7 @@ class SentryTest { it.dsn = dsn it.cacheDirPath = path it.isEnableAppStartProfiling = false - it.experimental.isStartProfilerOnAppStart = true + it.isStartProfilerOnAppStart = true it.tracesSampleRate = 0.0 it.executorService = ImmediateExecutorService() } @@ -1267,7 +1267,7 @@ class SentryTest { it.cacheDirPath = path it.tracesSampleRate = 0.5 it.isEnableAppStartProfiling = true - it.experimental.isStartProfilerOnAppStart = true + it.isStartProfilerOnAppStart = true it.profilesSampleRate = 0.2 it.executorService = ImmediateExecutorService() options = it @@ -1352,7 +1352,7 @@ class SentryTest { Sentry.init { it.dsn = dsn it.setContinuousProfiler(profiler) - it.experimental.profileSessionSampleRate = 1.0 + it.profileSessionSampleRate = 1.0 } Sentry.startProfiler() verify(profiler).startProfiler(eq(ProfileLifecycle.MANUAL), any()) @@ -1377,8 +1377,8 @@ class SentryTest { Sentry.init { it.dsn = dsn it.setContinuousProfiler(profiler) - it.experimental.profileSessionSampleRate = 1.0 - it.experimental.profileLifecycle = ProfileLifecycle.TRACE + it.profileSessionSampleRate = 1.0 + it.profileLifecycle = ProfileLifecycle.TRACE it.isDebug = true it.setLogger(logger) } @@ -1397,7 +1397,7 @@ class SentryTest { Sentry.init { it.dsn = dsn it.setContinuousProfiler(profiler) - it.experimental.profileSessionSampleRate = 1.0 + it.profileSessionSampleRate = 1.0 } Sentry.stopProfiler() verify(profiler).stopProfiler(eq(ProfileLifecycle.MANUAL)) diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 0c36034c287..4af0b033e71 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -263,7 +263,7 @@ class SentryTracerTest { whenever(continuousProfiler.profilerId).thenReturn(profilerId) val tracer = fixture.getSut(optionsConfiguration = { it.setContinuousProfiler(continuousProfiler) - it.experimental.profileLifecycle = ProfileLifecycle.MANUAL + it.profileLifecycle = ProfileLifecycle.MANUAL }, samplingDecision = TracesSamplingDecision(true)) tracer.finish() // profiler is never stopped, as it should be stopped manually diff --git a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt index 99718735a1e..eb6f8cd4623 100644 --- a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt +++ b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt @@ -30,7 +30,7 @@ class TracesSamplerTest { options.profilesSampleRate = profilesSampleRate } if (profileSessionSampleRate != null) { - options.experimental.profileSessionSampleRate = profileSessionSampleRate + options.profileSessionSampleRate = profileSessionSampleRate } if (tracesSamplerCallback != null) { options.tracesSampler = tracesSamplerCallback From a17f72e8291de115f6e0916735c95e30648160bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Apr 2025 10:43:25 +0200 Subject: [PATCH 011/846] Bump actions/create-github-app-token from 1.12.0 to 2.0.2 (#4319) Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 1.12.0 to 2.0.2. - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Commits](https://github.com/actions/create-github-app-token/compare/d72941d797fd3113feb6b93fd0dec494b13a2547...3ff1caaa28b64c9cc276ce0a02e2ff584f3900c5) --- updated-dependencies: - dependency-name: actions/create-github-app-token dependency-version: 2.0.2 dependency-type: direct:production update-type: version-update:semver-major ... 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 fe4dc5b3810..dcdc7bdefe7 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@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + uses: actions/create-github-app-token@3ff1caaa28b64c9cc276ce0a02e2ff584f3900c5 # v2.0.2 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From 5c3cd7ab53afeb0eae61c0bbed973b579b6c0467 Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 7 Apr 2025 11:18:28 +0200 Subject: [PATCH 012/846] Continuous Profiling - Add delayed stop (#4293) * replaced synchronized blocks with AutoClosableReentrantLock in AndroidContinuousProfiler * Added "delayed" stop of profiler, which stops the profiler after the current chunk is finished * Added default span data (profiler id, thread name and thread id) to transaction root span --- CHANGELOG.md | 1 + .../core/AndroidContinuousProfiler.java | 239 ++++++++++-------- .../core/AndroidContinuousProfilerTest.kt | 100 +++----- .../src/main/kotlin/io/sentry/test/Mocks.kt | 5 +- .../src/main/java/io/sentry/SentryTracer.java | 24 +- .../test/java/io/sentry/SentryTracerTest.kt | 9 + 6 files changed, 200 insertions(+), 178 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edb3fd60346..c79b99f7237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Continuous Profiling - Add delayed stop ([#4293](https://github.com/getsentry/sentry-java/pull/4293)) - Continuous Profiling - Out of Experimental ([#4310](https://github.com/getsentry/sentry-java/pull/4310)) ## 8.6.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index 0c66e1adfdf..94be555104d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -11,6 +11,7 @@ import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.ISentryExecutorService; +import io.sentry.ISentryLifecycleToken; import io.sentry.NoOpScopes; import io.sentry.PerformanceCollectionData; import io.sentry.ProfileChunk; @@ -24,6 +25,7 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.SentryId; import io.sentry.transport.RateLimiter; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.SentryRandom; import java.util.ArrayList; import java.util.List; @@ -57,10 +59,14 @@ public class AndroidContinuousProfiler private @NotNull SentryId chunkId = SentryId.EMPTY_ID; private final @NotNull AtomicBoolean isClosed = new AtomicBoolean(false); private @NotNull SentryDate startProfileChunkTimestamp = new SentryNanotimeDate(); - private boolean shouldSample = true; + private volatile boolean shouldSample = true; + private boolean shouldStop = false; private boolean isSampled = false; private int rootSpanCounter = 0; + private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private final AutoClosableReentrantLock payloadLock = new AutoClosableReentrantLock(); + public AndroidContinuousProfiler( final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull SentryFrameMetricsCollector frameMetricsCollector, @@ -106,42 +112,46 @@ private void init() { } @Override - public synchronized void startProfiler( + public void startProfiler( final @NotNull ProfileLifecycle profileLifecycle, final @NotNull TracesSampler tracesSampler) { - if (shouldSample) { - isSampled = tracesSampler.sampleSessionProfile(SentryRandom.current().nextDouble()); - shouldSample = false; - } - if (!isSampled) { - logger.log(SentryLevel.DEBUG, "Profiler was not started due to sampling decision."); - return; - } - switch (profileLifecycle) { - case TRACE: - // rootSpanCounter should never be negative, unless the user changed profile lifecycle while - // the profiler is running or close() is called. This is just a safety check. - if (rootSpanCounter < 0) { - rootSpanCounter = 0; - } - rootSpanCounter++; - break; - case MANUAL: - // We check if the profiler is already running and log a message only in manual mode, since - // in trace mode we can have multiple concurrent traces - if (isRunning()) { - logger.log(SentryLevel.DEBUG, "Profiler is already running."); - return; - } - break; - } - if (!isRunning()) { - logger.log(SentryLevel.DEBUG, "Started Profiler."); - start(); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (shouldSample) { + isSampled = tracesSampler.sampleSessionProfile(SentryRandom.current().nextDouble()); + shouldSample = false; + } + if (!isSampled) { + logger.log(SentryLevel.DEBUG, "Profiler was not started due to sampling decision."); + return; + } + switch (profileLifecycle) { + case TRACE: + // rootSpanCounter should never be negative, unless the user changed profile lifecycle + // while + // the profiler is running or close() is called. This is just a safety check. + if (rootSpanCounter < 0) { + rootSpanCounter = 0; + } + rootSpanCounter++; + break; + case MANUAL: + // We check if the profiler is already running and log a message only in manual mode, + // since + // in trace mode we can have multiple concurrent traces + if (isRunning()) { + logger.log(SentryLevel.DEBUG, "Profiler is already running."); + return; + } + break; + } + if (!isRunning()) { + logger.log(SentryLevel.DEBUG, "Started Profiler."); + start(); + } } } - private synchronized void start() { + private void start() { if ((scopes == null || scopes == NoOpScopes.getInstance()) && Sentry.getCurrentScopes() != NoOpScopes.getInstance()) { this.scopes = Sentry.getCurrentScopes(); @@ -213,103 +223,112 @@ private synchronized void start() { SentryLevel.ERROR, "Failed to schedule profiling chunk finish. Did you call Sentry.close()?", e); + shouldStop = true; } } @Override - public synchronized void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) { - switch (profileLifecycle) { - case TRACE: - rootSpanCounter--; - // If there are active spans, and profile lifecycle is trace, we don't stop the profiler - if (rootSpanCounter > 0) { - return; - } - // rootSpanCounter should never be negative, unless the user changed profile lifecycle while - // the profiler is running or close() is called. This is just a safety check. - if (rootSpanCounter < 0) { - rootSpanCounter = 0; - } - stop(false); - break; - case MANUAL: - stop(false); - break; + public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + switch (profileLifecycle) { + case TRACE: + rootSpanCounter--; + // If there are active spans, and profile lifecycle is trace, we don't stop the profiler + if (rootSpanCounter > 0) { + return; + } + // rootSpanCounter should never be negative, unless the user changed profile lifecycle + // while the profiler is running or close() is called. This is just a safety check. + if (rootSpanCounter < 0) { + rootSpanCounter = 0; + } + shouldStop = true; + break; + case MANUAL: + shouldStop = true; + break; + } } } - private synchronized void stop(final boolean restartProfiler) { - if (stopFuture != null) { - stopFuture.cancel(true); - } - // check if profiler was created and it's running - if (profiler == null || !isRunning) { - // When the profiler is stopped due to an error (e.g. offline or rate limited), reset the ids - profilerId = SentryId.EMPTY_ID; - chunkId = SentryId.EMPTY_ID; - return; - } - - // onTransactionStart() is only available since Lollipop_MR1 - // and SystemClock.elapsedRealtimeNanos() since Jelly Bean - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) { - return; - } + private void stop(final boolean restartProfiler) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (stopFuture != null) { + stopFuture.cancel(true); + } + // check if profiler was created and it's running + if (profiler == null || !isRunning) { + // When the profiler is stopped due to an error (e.g. offline or rate limited), reset the + // ids + profilerId = SentryId.EMPTY_ID; + chunkId = SentryId.EMPTY_ID; + return; + } - List performanceCollectionData = null; - if (performanceCollector != null) { - performanceCollectionData = performanceCollector.stop(chunkId.toString()); - } + // onTransactionStart() is only available since Lollipop_MR1 + // and SystemClock.elapsedRealtimeNanos() since Jelly Bean + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) { + return; + } - final AndroidProfiler.ProfileEndData endData = - profiler.endAndCollect(false, performanceCollectionData); + List performanceCollectionData = null; + if (performanceCollector != null) { + performanceCollectionData = performanceCollector.stop(chunkId.toString()); + } - // check if profiler end successfully - if (endData == null) { - logger.log( - SentryLevel.ERROR, - "An error occurred while collecting a profile chunk, and it won't be sent."); - } else { - // The scopes can be null if the profiler is started before the SDK is initialized (app start - // profiling), meaning there's no scopes to send the chunks. In that case, we store the data - // in a list and send it when the next chunk is finished. - synchronized (payloadBuilders) { - payloadBuilders.add( - new ProfileChunk.Builder( - profilerId, - chunkId, - endData.measurementsMap, - endData.traceFile, - startProfileChunkTimestamp)); + final AndroidProfiler.ProfileEndData endData = + profiler.endAndCollect(false, performanceCollectionData); + + // check if profiler end successfully + if (endData == null) { + logger.log( + SentryLevel.ERROR, + "An error occurred while collecting a profile chunk, and it won't be sent."); + } else { + // The scopes can be null if the profiler is started before the SDK is initialized (app + // start profiling), meaning there's no scopes to send the chunks. In that case, we store + // the data in a list and send it when the next chunk is finished. + try (final @NotNull ISentryLifecycleToken ignored2 = payloadLock.acquire()) { + payloadBuilders.add( + new ProfileChunk.Builder( + profilerId, + chunkId, + endData.measurementsMap, + endData.traceFile, + startProfileChunkTimestamp)); + } } - } - isRunning = false; - // A chunk is finished. Next chunk will have a different id. - chunkId = SentryId.EMPTY_ID; + isRunning = false; + // A chunk is finished. Next chunk will have a different id. + chunkId = SentryId.EMPTY_ID; - if (scopes != null) { - sendChunks(scopes, scopes.getOptions()); - } + if (scopes != null) { + sendChunks(scopes, scopes.getOptions()); + } - if (restartProfiler) { - logger.log(SentryLevel.DEBUG, "Profile chunk finished. Starting a new one."); - start(); - } else { - // When the profiler is stopped manually, we have to reset its id - profilerId = SentryId.EMPTY_ID; - logger.log(SentryLevel.DEBUG, "Profile chunk finished."); + if (restartProfiler && !shouldStop) { + logger.log(SentryLevel.DEBUG, "Profile chunk finished. Starting a new one."); + start(); + } else { + // When the profiler is stopped manually, we have to reset its id + profilerId = SentryId.EMPTY_ID; + logger.log(SentryLevel.DEBUG, "Profile chunk finished."); + } } } - public synchronized void reevaluateSampling() { + public void reevaluateSampling() { shouldSample = true; } - public synchronized void close() { - rootSpanCounter = 0; - stop(false); - isClosed.set(true); + public void close() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + rootSpanCounter = 0; + shouldStop = true; + stop(false); + isClosed.set(true); + } } @Override @@ -328,7 +347,7 @@ private void sendChunks(final @NotNull IScopes scopes, final @NotNull SentryOpti return; } final ArrayList payloads = new ArrayList<>(payloadBuilders.size()); - synchronized (payloadBuilders) { + try (final @NotNull ISentryLifecycleToken ignored = payloadLock.acquire()) { for (ProfileChunk.Builder builder : payloadBuilders) { payloads.add(builder.build(options)); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index daf7c84d156..4e1b45ebb02 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt @@ -10,7 +10,6 @@ import io.sentry.DataCategory import io.sentry.IConnectionStatusProvider import io.sentry.ILogger import io.sentry.IScopes -import io.sentry.ISentryExecutorService import io.sentry.MemoryCollectionData import io.sentry.PerformanceCollectionData import io.sentry.ProfileLifecycle @@ -39,7 +38,6 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.io.File -import java.util.concurrent.Callable import java.util.concurrent.Future import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -61,6 +59,7 @@ class AndroidContinuousProfilerTest { val buildInfo = mock { whenever(it.sdkInfoVersion).thenReturn(Build.VERSION_CODES.LOLLIPOP_MR1) } + val executor = DeferredExecutorService() val mockedSentry = mockStatic(Sentry::class.java) val mockLogger = mock() val mockTracesSampler = mock() @@ -84,6 +83,7 @@ class AndroidContinuousProfilerTest { } fun getSut(buildInfoProvider: BuildInfoProvider = buildInfo, optionConfig: ((options: SentryAndroidOptions) -> Unit) = {}): AndroidContinuousProfiler { + options.executorService = executor optionConfig(options) whenever(scopes.options).thenReturn(options) transaction1 = SentryTracer(TransactionContext("", ""), scopes) @@ -152,6 +152,20 @@ class AndroidContinuousProfilerTest { profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) profiler.stopProfiler(ProfileLifecycle.MANUAL) + fixture.executor.runAll() + assertFalse(profiler.isRunning) + } + + @Test + fun `stopProfiler stops the profiler after chunk is finished`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + // We are scheduling the profiler to stop at the end of the chunk, so it should still be running + profiler.stopProfiler(ProfileLifecycle.MANUAL) + assertTrue(profiler.isRunning) + // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart + fixture.executor.runAll() assertFalse(profiler.isRunning) } @@ -183,11 +197,13 @@ class AndroidContinuousProfilerTest { // rootSpanCounter is decremented when the profiler stops in trace mode, and keeps running until rootSpanCounter is 0 profiler.stopProfiler(ProfileLifecycle.TRACE) + fixture.executor.runAll() assertEquals(1, profiler.rootSpanCounter) assertTrue(profiler.isRunning) // only when rootSpanCounter is 0 the profiler stops profiler.stopProfiler(ProfileLifecycle.TRACE) + fixture.executor.runAll() assertEquals(0, profiler.rootSpanCounter) assertFalse(profiler.isRunning) } @@ -316,19 +332,6 @@ class AndroidContinuousProfilerTest { assertFalse(profiler.isRunning) } - @Test - fun `profiler never use background threads`() { - val mockExecutorService: ISentryExecutorService = mock() - val profiler = fixture.getSut { - it.executorService = mockExecutorService - } - whenever(mockExecutorService.submit(any>())).thenReturn(mock()) - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - verify(mockExecutorService, never()).submit(any()) - profiler.stopProfiler(ProfileLifecycle.MANUAL) - verify(mockExecutorService, never()).submit(any>()) - } - @Test fun `profiler does not throw if traces cannot be written to disk`() { val profiler = fixture.getSut { @@ -336,6 +339,7 @@ class AndroidContinuousProfilerTest { } profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.stopProfiler(ProfileLifecycle.MANUAL) + fixture.executor.runAll() // We assert that no trace files are written assertTrue( File(fixture.options.profilingTracesDirPath!!) @@ -363,6 +367,7 @@ class AndroidContinuousProfilerTest { profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) verify(performanceCollector, never()).stop(any()) profiler.stopProfiler(ProfileLifecycle.MANUAL) + fixture.executor.runAll() verify(performanceCollector).stop(any()) } @@ -374,6 +379,7 @@ class AndroidContinuousProfilerTest { profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) verify(fixture.frameMetricsCollector, never()).stopCollection(frameMetricsCollectorId) profiler.stopProfiler(ProfileLifecycle.MANUAL) + fixture.executor.runAll() verify(fixture.frameMetricsCollector).stopCollection(frameMetricsCollectorId) } @@ -393,46 +399,39 @@ class AndroidContinuousProfilerTest { val stopFuture = profiler.stopFuture assertNotNull(stopFuture) - assertTrue(stopFuture.isCancelled) + assertTrue(stopFuture.isCancelled || stopFuture.isDone) } @Test fun `profiler stops and restart for each chunk`() { - val executorService = DeferredExecutorService() - val profiler = fixture.getSut { - it.executorService = executorService - } + val profiler = fixture.getSut() profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) - executorService.runAll() + fixture.executor.runAll() verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) assertTrue(profiler.isRunning) - executorService.runAll() + fixture.executor.runAll() verify(fixture.mockLogger, times(2)).log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) assertTrue(profiler.isRunning) } @Test fun `profiler sends chunk on each restart`() { - val executorService = DeferredExecutorService() - val profiler = fixture.getSut { - it.executorService = executorService - } + val profiler = fixture.getSut() profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) // We run the executor service to trigger the profiler restart (chunk finish) - executorService.runAll() + fixture.executor.runAll() verify(fixture.scopes, never()).captureProfileChunk(any()) // Now the executor is used to send the chunk - executorService.runAll() + fixture.executor.runAll() verify(fixture.scopes).captureProfileChunk(any()) } @Test fun `profiler sends chunk with measurements`() { - val executorService = DeferredExecutorService() val performanceCollector = mock() val collectionData = PerformanceCollectionData() @@ -441,13 +440,13 @@ class AndroidContinuousProfilerTest { whenever(performanceCollector.stop(any())).thenReturn(listOf(collectionData)) fixture.options.compositePerformanceCollector = performanceCollector - val profiler = fixture.getSut { - it.executorService = executorService - } + val profiler = fixture.getSut() profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.stopProfiler(ProfileLifecycle.MANUAL) - // We run the executor service to send the profile chunk - executorService.runAll() + // We run the executor service to stop the profiler + fixture.executor.runAll() + // Then we run it again to send the profile chunk + fixture.executor.runAll() verify(fixture.scopes).captureProfileChunk( check { assertContains(it.measurements, ProfileMeasurement.ID_CPU_USAGE) @@ -459,28 +458,21 @@ class AndroidContinuousProfilerTest { @Test fun `profiler sends another chunk on stop`() { - val executorService = DeferredExecutorService() - val profiler = fixture.getSut { - it.executorService = executorService - } + val profiler = fixture.getSut() profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) // We run the executor service to trigger the profiler restart (chunk finish) - executorService.runAll() + fixture.executor.runAll() verify(fixture.scopes, never()).captureProfileChunk(any()) - // We stop the profiler, which should send an additional chunk profiler.stopProfiler(ProfileLifecycle.MANUAL) - // Now the executor is used to send the chunk - executorService.runAll() - verify(fixture.scopes, times(2)).captureProfileChunk(any()) + // We stop the profiler, which should send a chunk + fixture.executor.runAll() + verify(fixture.scopes).captureProfileChunk(any()) } @Test fun `profiler does not send chunks after close`() { - val executorService = DeferredExecutorService() - val profiler = fixture.getSut { - it.executorService = executorService - } + val profiler = fixture.getSut() profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) @@ -488,16 +480,13 @@ class AndroidContinuousProfilerTest { profiler.close() // The executor used to send the chunk doesn't do anything - executorService.runAll() + fixture.executor.runAll() verify(fixture.scopes, never()).captureProfileChunk(any()) } @Test fun `profiler stops when rate limited`() { - val executorService = DeferredExecutorService() - val profiler = fixture.getSut { - it.executorService = executorService - } + val profiler = fixture.getSut() val rateLimiter = mock() whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunk)).thenReturn(true) @@ -513,10 +502,7 @@ class AndroidContinuousProfilerTest { @Test fun `profiler does not start when rate limited`() { - val executorService = DeferredExecutorService() - val profiler = fixture.getSut { - it.executorService = executorService - } + val profiler = fixture.getSut() val rateLimiter = mock() whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunk)).thenReturn(true) whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) @@ -530,9 +516,7 @@ class AndroidContinuousProfilerTest { @Test fun `profiler does not start when offline`() { - val executorService = DeferredExecutorService() val profiler = fixture.getSut { - it.executorService = executorService it.connectionStatusProvider = mock { provider -> whenever(provider.connectionStatus).thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) } diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt index b30fe3464f8..d048b42d428 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt @@ -13,6 +13,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.util.concurrent.Callable import java.util.concurrent.Future +import java.util.concurrent.FutureTask import java.util.concurrent.atomic.AtomicBoolean class ImmediateExecutorService : ISentryExecutorService { @@ -58,7 +59,7 @@ class DeferredExecutorService : ISentryExecutorService { synchronized(this) { runnables.add(runnable) } - return mock() + return FutureTask {} } override fun submit(callable: Callable): Future = mock() @@ -66,7 +67,7 @@ class DeferredExecutorService : ISentryExecutorService { synchronized(this) { scheduledRunnables.add(runnable) } - return mock() + return FutureTask {} } override fun close(timeoutMillis: Long) {} override fun isClosed(): Boolean = false diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 8da554cd3e9..0496f407219 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -81,6 +81,8 @@ public SentryTracer( this.transactionNameSource = context.getTransactionNameSource(); this.transactionOptions = transactionOptions; + setDefaultSpanData(root); + final @NotNull SentryId continuousProfilerId = scopes.getOptions().getContinuousProfiler().getProfilerId(); if (!continuousProfilerId.equals(SentryId.EMPTY_ID) && Boolean.TRUE.equals(isSampled())) { @@ -519,14 +521,7 @@ private ISpan createChild( // } // }); // span.setDescription(description); - final @NotNull IThreadChecker threadChecker = scopes.getOptions().getThreadChecker(); - final SentryId profilerId = scopes.getOptions().getContinuousProfiler().getProfilerId(); - if (!profilerId.equals(SentryId.EMPTY_ID) && Boolean.TRUE.equals(span.isSampled())) { - span.setData(SpanDataConvention.PROFILER_ID, profilerId.toString()); - } - span.setData( - SpanDataConvention.THREAD_ID, String.valueOf(threadChecker.currentThreadSystemId())); - span.setData(SpanDataConvention.THREAD_NAME, threadChecker.getCurrentThreadName()); + setDefaultSpanData(span); this.children.add(span); if (compositePerformanceCollector != null) { compositePerformanceCollector.onSpanStarted(span); @@ -545,6 +540,19 @@ private ISpan createChild( } } + /** Sets the default data in the span, including profiler _id, thread id and thread name */ + private void setDefaultSpanData(final @NotNull ISpan span) { + final @NotNull IThreadChecker threadChecker = scopes.getOptions().getThreadChecker(); + final @NotNull SentryId profilerId = + scopes.getOptions().getContinuousProfiler().getProfilerId(); + if (!profilerId.equals(SentryId.EMPTY_ID) && Boolean.TRUE.equals(span.isSampled())) { + span.setData(SpanDataConvention.PROFILER_ID, profilerId.toString()); + } + span.setData( + SpanDataConvention.THREAD_ID, String.valueOf(threadChecker.currentThreadSystemId())); + span.setData(SpanDataConvention.THREAD_NAME, threadChecker.getCurrentThreadName()); + } + @Override public @NotNull ISpan startChild(final @NotNull String operation) { return this.startChild(operation, (String) null); diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 4af0b033e71..85bbf0fd90f 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -86,6 +86,15 @@ class SentryTracerTest { assertEquals("new-origin", transaction.spanContext.origin) } + @Test + fun `root span has thread name and thread id in the data`() { + val tracer = fixture.getSut() + assertTrue(tracer.root.data.containsKey(SpanDataConvention.THREAD_NAME)) + assertTrue(tracer.root.data.containsKey(SpanDataConvention.THREAD_ID)) + assertTrue(tracer.data!!.containsKey(SpanDataConvention.THREAD_NAME)) + assertTrue(tracer.data!!.containsKey(SpanDataConvention.THREAD_ID)) + } + @Test fun `does not create child span if origin is ignored`() { val tracer = fixture.getSut({ From 1e82a71753c8c74a8c26f40c73b78d02cd0265df Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 7 Apr 2025 13:13:58 +0200 Subject: [PATCH 013/846] Compress Screenshots on a background thread (#4295) * Compress Screenshots on a background thread * Update Changelog * Recover APIs used by hybrid SDKs * Recycle bitmap after compression --- CHANGELOG.md | 4 ++ .../core/ScreenshotEventProcessor.java | 15 ++++-- .../core/internal/util/ScreenshotUtils.java | 50 ++++++++++++++++- .../core/internal/util/ScreenshotUtilTest.kt | 47 +++++++++++++--- sentry/api/sentry.api | 3 ++ .../src/main/java/io/sentry/Attachment.java | 54 ++++++++++++++++++- .../java/io/sentry/SentryEnvelopeItem.java | 11 +++- .../java/io/sentry/SentryEnvelopeItemTest.kt | 25 +++++++++ 8 files changed, 195 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c79b99f7237..1d1497bffe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Continuous Profiling - Add delayed stop ([#4293](https://github.com/getsentry/sentry-java/pull/4293)) - Continuous Profiling - Out of Experimental ([#4310](https://github.com/getsentry/sentry-java/pull/4310)) +### Fixes + +- Compress Screenshots on a background thread ([#4295](https://github.com/getsentry/sentry-java/pull/4295)) + ## 8.6.0 ### Behavioral Changes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 8585cb96142..16e96979454 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -1,10 +1,11 @@ package io.sentry.android.core; import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY; -import static io.sentry.android.core.internal.util.ScreenshotUtils.takeScreenshot; +import static io.sentry.android.core.internal.util.ScreenshotUtils.captureScreenshot; import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.app.Activity; +import android.graphics.Bitmap; import io.sentry.Attachment; import io.sentry.EventProcessor; import io.sentry.Hint; @@ -12,6 +13,7 @@ import io.sentry.SentryLevel; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.Debouncer; +import io.sentry.android.core.internal.util.ScreenshotUtils; import io.sentry.protocol.SentryTransaction; import io.sentry.util.HintUtils; import io.sentry.util.Objects; @@ -87,14 +89,19 @@ public ScreenshotEventProcessor( return event; } - final byte[] screenshot = - takeScreenshot( + final Bitmap screenshot = + captureScreenshot( activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); if (screenshot == null) { return event; } - hint.setScreenshot(Attachment.fromScreenshot(screenshot)); + hint.setScreenshot( + Attachment.fromByteProvider( + () -> ScreenshotUtils.compressBitmapToPng(screenshot, options.getLogger()), + "screenshot.png", + "image/png", + false)); hint.set(ANDROID_ACTIVITY, activity); return event; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index d6cd7bc6af9..db2b12122a5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -27,6 +27,10 @@ public class ScreenshotUtils { private static final long CAPTURE_TIMEOUT_MS = 1000; + // Used by Hybrid SDKs + /** + * @noinspection unused + */ public static @Nullable byte[] takeScreenshot( final @NotNull Activity activity, final @NotNull ILogger logger, @@ -34,12 +38,33 @@ public class ScreenshotUtils { return takeScreenshot(activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider); } + // Used by Hybrid SDKs @SuppressLint("NewApi") public static @Nullable byte[] takeScreenshot( final @NotNull Activity activity, final @NotNull IThreadChecker threadChecker, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { + + final @Nullable Bitmap screenshot = + captureScreenshot(activity, threadChecker, logger, buildInfoProvider); + return compressBitmapToPng(screenshot, logger); + } + + public static @Nullable Bitmap captureScreenshot( + final @NotNull Activity activity, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + return captureScreenshot( + activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider); + } + + @SuppressLint("NewApi") + public static @Nullable Bitmap captureScreenshot( + final @NotNull Activity activity, + final @NotNull IThreadChecker threadChecker, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { // We are keeping BuildInfoProvider param for compatibility, as it's being used by // cross-platform SDKs @@ -71,7 +96,7 @@ public class ScreenshotUtils { return null; } - try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + try { // ARGB_8888 -> This configuration is very flexible and offers the best quality final Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); @@ -132,10 +157,31 @@ public class ScreenshotUtils { return null; } } + return bitmap; + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e); + } + return null; + } + /** + * Compresses the supplied Bitmap to a PNG byte array. After compression, the Bitmap will be + * recycled. + * + * @param bitmap The bitmap to compress + * @param logger the logger + * @return the Bitmap in PNG format, or null if the bitmap was null, recycled or compressing faile + */ + public static @Nullable byte[] compressBitmapToPng( + final @Nullable Bitmap bitmap, final @NotNull ILogger logger) { + if (bitmap == null || bitmap.isRecycled()) { + return null; + } + try (final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { // 0 meaning compress for small size, 100 meaning compress for max quality. // Some formats, like PNG which is lossless, will ignore the quality setting. bitmap.compress(Bitmap.CompressFormat.PNG, 0, byteArrayOutputStream); + bitmap.recycle(); if (byteArrayOutputStream.size() <= 0) { logger.log(SentryLevel.DEBUG, "Screenshot is 0 bytes, not attaching the image."); @@ -145,7 +191,7 @@ public class ScreenshotUtils { // screenshot png is around ~100-150 kb return byteArrayOutputStream.toByteArray(); } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Taking screenshot failed.", e); + logger.log(SentryLevel.ERROR, "Compressing bitmap failed.", e); } return null; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt index 18eecf3128d..10369063f6a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/ScreenshotUtilTest.kt @@ -1,12 +1,14 @@ package io.sentry.android.core.internal.util import android.app.Activity +import android.graphics.Bitmap import android.os.Build import android.os.Bundle import android.view.View import android.view.Window import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger +import io.sentry.NoOpLogger import io.sentry.android.core.BuildInfoProvider import junit.framework.TestCase.assertNull import org.junit.runner.RunWith @@ -16,7 +18,9 @@ import org.robolectric.Robolectric.buildActivity import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowPixelCopy import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue @Config( shadows = [ShadowPixelCopy::class], @@ -32,7 +36,7 @@ class ScreenshotUtilTest { whenever(activity.isDestroyed).thenReturn(false) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -44,7 +48,7 @@ class ScreenshotUtilTest { whenever(activity.window).thenReturn(mock()) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -60,7 +64,7 @@ class ScreenshotUtilTest { whenever(window.peekDecorView()).thenReturn(decorView) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -81,7 +85,7 @@ class ScreenshotUtilTest { whenever(rootView.height).thenReturn(0) val data = - ScreenshotUtils.takeScreenshot(activity, mock(), mock()) + ScreenshotUtils.captureScreenshot(activity, mock(), mock()) assertNull(data) } @@ -94,7 +98,7 @@ class ScreenshotUtilTest { val buildInfoProvider = mock() whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.O) - val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider) + val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider) assertNotNull(data) } @@ -107,9 +111,40 @@ class ScreenshotUtilTest { val buildInfoProvider = mock() whenever(buildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - val data = ScreenshotUtils.takeScreenshot(controller.get(), logger, buildInfoProvider) + val data = ScreenshotUtils.captureScreenshot(controller.get(), logger, buildInfoProvider) assertNotNull(data) } + + @Test + fun `a null bitmap compresses into null`() { + val bytes = ScreenshotUtils.compressBitmapToPng(null, NoOpLogger.getInstance()) + assertNull(bytes) + } + + @Test + fun `a recycled bitmap compresses into null`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + bitmap.recycle() + + val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertNull(bytes) + } + + @Test + fun `a valid bitmap compresses into a valid bytearray`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val bytes = ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertNotNull(bytes) + assertTrue(bytes.isNotEmpty()) + } + + @Test + fun `compressBitmapToPng recycles the supplied bitmap`() { + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + assertFalse(bitmap.isRecycled) + ScreenshotUtils.compressBitmapToPng(bitmap, NoOpLogger.getInstance()) + assertTrue(bitmap.isRecycled) + } } class ExampleActivity : Activity() { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0aafb1181cb..15fb6dadffd 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -11,14 +11,17 @@ public final class io/sentry/Attachment { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)V + public fun (Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun ([BLjava/lang/String;)V public fun ([BLjava/lang/String;Ljava/lang/String;)V public fun ([BLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V public fun ([BLjava/lang/String;Ljava/lang/String;Z)V + public static fun fromByteProvider (Ljava/util/concurrent/Callable;Ljava/lang/String;Ljava/lang/String;Z)Lio/sentry/Attachment; public static fun fromScreenshot ([B)Lio/sentry/Attachment; public static fun fromThreadDump ([B)Lio/sentry/Attachment; public static fun fromViewHierarchy (Lio/sentry/protocol/ViewHierarchy;)Lio/sentry/Attachment; public fun getAttachmentType ()Ljava/lang/String; + public fun getByteProvider ()Ljava/util/concurrent/Callable; public fun getBytes ()[B public fun getContentType ()Ljava/lang/String; public fun getFilename ()Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/Attachment.java b/sentry/src/main/java/io/sentry/Attachment.java index 7a4ec3b99dc..439ad812b0c 100644 --- a/sentry/src/main/java/io/sentry/Attachment.java +++ b/sentry/src/main/java/io/sentry/Attachment.java @@ -2,6 +2,7 @@ import io.sentry.protocol.ViewHierarchy; import java.io.File; +import java.util.concurrent.Callable; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -10,6 +11,7 @@ public final class Attachment { private @Nullable byte[] bytes; private final @Nullable JsonSerializable serializable; + private final @Nullable Callable byteProvider; private @Nullable String pathname; private final @NotNull String filename; private final @Nullable String contentType; @@ -84,6 +86,7 @@ public Attachment( final boolean addToTransactions) { this.bytes = bytes; this.serializable = null; + this.byteProvider = null; this.filename = filename; this.contentType = contentType; this.attachmentType = attachmentType; @@ -109,6 +112,33 @@ public Attachment( final boolean addToTransactions) { this.bytes = null; this.serializable = serializable; + this.byteProvider = null; + this.filename = filename; + this.contentType = contentType; + this.attachmentType = attachmentType; + this.addToTransactions = addToTransactions; + } + + /** + * Initializes an Attachment with bytes factory, a filename, a content type, and + * addToTransactions. + * + * @param byteProvider A provider holding the attachment payload + * @param filename The name of the attachment to display in Sentry. + * @param contentType The content type of the attachment. + * @param attachmentType the attachment type. + * @param addToTransactions true if the SDK should add this attachment to every + * {@link ITransaction} or set to false if it shouldn't. + */ + public Attachment( + final @NotNull Callable byteProvider, + final @NotNull String filename, + final @Nullable String contentType, + final @Nullable String attachmentType, + final boolean addToTransactions) { + this.bytes = null; + this.serializable = null; + this.byteProvider = byteProvider; this.filename = filename; this.contentType = contentType; this.attachmentType = attachmentType; @@ -186,6 +216,7 @@ public Attachment( this.pathname = pathname; this.filename = filename; this.serializable = null; + this.byteProvider = null; this.contentType = contentType; this.attachmentType = attachmentType; this.addToTransactions = addToTransactions; @@ -212,6 +243,7 @@ public Attachment( this.pathname = pathname; this.filename = filename; this.serializable = null; + this.byteProvider = null; this.contentType = contentType; this.addToTransactions = addToTransactions; } @@ -240,6 +272,7 @@ public Attachment( this.pathname = pathname; this.filename = filename; this.serializable = null; + this.byteProvider = null; this.contentType = contentType; this.addToTransactions = addToTransactions; this.attachmentType = attachmentType; @@ -310,16 +343,35 @@ boolean isAddToTransactions() { return attachmentType; } + public @Nullable Callable getByteProvider() { + return byteProvider; + } + /** * Creates a new Screenshot Attachment * - * @param screenshotBytes the array bytes + * @param screenshotBytes the array bytes of the PNG screenshot * @return the Attachment */ public static @NotNull Attachment fromScreenshot(final byte[] screenshotBytes) { return new Attachment(screenshotBytes, "screenshot.png", "image/png", false); } + /** + * Creates a new Screenshot Attachment + * + * @param provider the mechanism providing the screenshot payload + * @return the Attachment + */ + public static @NotNull Attachment fromByteProvider( + final @NotNull Callable provider, + final @NotNull String filename, + final @Nullable String contentType, + final boolean addToTransactions) { + return new Attachment( + provider, filename, contentType, DEFAULT_ATTACHMENT_TYPE, addToTransactions); + } + /** * Creates a new View Hierarchy Attachment * diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 9a76d118a92..43ededf6a88 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -213,6 +213,7 @@ public static SentryEnvelopeItem fromAttachment( return data; } else if (attachment.getSerializable() != null) { final JsonSerializable serializable = attachment.getSerializable(); + @SuppressWarnings("NullableProblems") final @Nullable byte[] data = JsonSerializationUtils.bytesFrom(serializer, logger, serializable); @@ -223,11 +224,19 @@ public static SentryEnvelopeItem fromAttachment( } } else if (attachment.getPathname() != null) { return readBytesFromFile(attachment.getPathname(), maxAttachmentSize); + } else if (attachment.getByteProvider() != null) { + @SuppressWarnings("NullableProblems") + final @Nullable byte[] data = attachment.getByteProvider().call(); + if (data != null) { + ensureAttachmentSizeLimit( + data.length, maxAttachmentSize, attachment.getFilename()); + return data; + } } throw new SentryEnvelopeException( String.format( "Couldn't attach the attachment %s.\n" - + "Please check that either bytes, serializable or a path is set.", + + "Please check that either bytes, serializable, path or provider is set.", attachment.getFilename())); }); diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 48df7ff0903..cd586ab9628 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -23,6 +23,7 @@ import java.io.InputStreamReader import java.io.OutputStreamWriter import java.nio.charset.Charset import java.nio.file.Files +import java.util.concurrent.Callable import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -103,6 +104,30 @@ class SentryEnvelopeItemTest { assertAttachment(attachment, viewHierarchySerialized, item) } + @Test + fun `fromAttachment with byteProvider`() { + val attachment = Attachment( + object : Callable { + override fun call(): ByteArray? { + return byteArrayOf(0x1) + } + }, + fixture.filename, + "text/plain", + "image/png", + false + ) + + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) + + assertAttachment(attachment, byteArrayOf(0x1), item) + } + @Test fun `fromAttachment with attachmentType`() { val attachment = Attachment(fixture.pathname, fixture.filename, "", true, "event.minidump") From f6625b00b5d4bac45eaf1350303fe3d4e0278e48 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 7 Apr 2025 17:11:26 +0200 Subject: [PATCH 014/846] Add test to verify transactions from sentry-native are sent to relay (#4312) --- .../io/sentry/uitest/android/EnvelopeTests.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt index c8ebc872640..ba725f805d3 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt @@ -22,6 +22,7 @@ import io.sentry.protocol.SentryTransaction import org.junit.Assume import org.junit.Assume.assumeNotNull import org.junit.runner.RunWith +import java.io.File import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals @@ -229,6 +230,36 @@ class EnvelopeTests : BaseUiTest() { Thread.sleep(5000) } + @Test + fun sendsNativeTransaction() { + var optionsRef: SentryAndroidOptions? = null + initSentry(true) { options -> + options.tracesSampleRate = 1.0 + optionsRef = options + } + + // based on https://github.com/getsentry/sentry-native/blob/20d5d5f75f1f48228f2f47e2bb99b17f9996ebbf/ndk/lib/src/androidTest/java/io/sentry/ndk/SentryNdkTest.java#L131 + File(optionsRef!!.outboxPath, "14779dbf-b2f0-4c00-f4e5-4a287abc4267") + .writeText( + """ + {"dsn":"https://key@sentry.io/proj","event_id":"729ff878-5539-458d-f657-a1acf423a127","sent_at":"2025-04-02T10:02:04.732577Z"} + {"type":"transaction","length":1335} + {"event_id":"729ff878-5539-458d-f657-a1acf423a127","platform":"native","transaction":"little.teapot","start_timestamp":"2025-04-02T10:02:04.731697Z","spans":[{"op":"littlest.teapot","span_id":"00028ba394454124","status":"ok","trace_id":"7160e289fe4c4496f02c72bbc7edb392","parent_span_id":"b0dc1649a8ec4101","description":null,"start_timestamp":"2025-04-02T10:02:04.732127Z","timestamp":"2025-04-02T10:02:04.732133Z"},{"op":"littler.teapot","span_id":"b0dc1649a8ec4101","status":"ok","trace_id":"7160e289fe4c4496f02c72bbc7edb392","parent_span_id":"7ad2e40529af4650","description":null,"start_timestamp":"2025-04-02T10:02:04.732118Z","data":{"span_data_says":"hi!"},"timestamp":"2025-04-02T10:02:04.732137Z"}],"type":"transaction","timestamp":"2025-04-02T10:02:04.732142Z","level":"info","contexts":{"trace":{"trace_id":"7160e289fe4c4496f02c72bbc7edb392","span_id":"7ad2e40529af4650","op":"Short and stout here is my handle and here is my spout","status":"ok","data":{"url":"https://example.com"}},"os":{"build":"android14-4-00257-g7e35917775b8-ab9964412","name":"Linux","version":"6.1.23"}},"release":"1.0.0","dist":"dist","environment":"production","sdk":{"name":"io.sentry.ndk","version":"0.8.3","packages":[{"name":"github:getsentry/sentry-native","version":"0.8.3"}],"integrations":["inproc"]},"tags":{},"extra":{},"breadcrumbs":[]} + """.trimIndent() + ) + + relayIdlingResource.increment() + + relay.assert { + assertFirstEnvelope { + val event: SentryEvent = it.assertItem() + it.assertNoOtherItems() + assertEquals("little.teapot", event.transaction) + } + assertNoOtherEnvelopes() + } + } + private fun swipeList(times: Int) { repeat(times) { Thread.sleep(100) From c4eeb3cc348dd8c9eef418e2767aacf98bf5ed3c Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 7 Apr 2025 17:50:53 +0200 Subject: [PATCH 015/846] Continuous Profiling - stop when app goes in background (#4311) * Replaced synchronized blocks with AutoClosableReentrantLock in AndroidContinuousProfiler * Added "delayed" stop of profiler, which stops the profiler after the current chunk is finished * Added default span data (profiler id, thread name and thread id) to transaction root span * App going in the background now stops the continuous profiler * Added isTerminating param to AndroidContinuousProfiler.close() --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 2 +- .../core/AndroidContinuousProfiler.java | 9 +++++--- .../core/AndroidOptionsInitializer.java | 2 +- .../sentry/android/core/LifecycleWatcher.java | 1 + .../core/SentryPerformanceProvider.java | 2 +- .../core/performance/AppStartMetrics.java | 4 ++-- .../core/AndroidContinuousProfilerTest.kt | 21 +++++++++++++++++-- .../core/AndroidOptionsInitializerTest.kt | 2 +- .../android/core/LifecycleWatcherTest.kt | 5 +++++ .../core/performance/AppStartMetricsTest.kt | 5 +++-- sentry/api/sentry.api | 4 ++-- .../java/io/sentry/IContinuousProfiler.java | 8 +++++-- .../io/sentry/NoOpContinuousProfiler.java | 2 +- sentry/src/main/java/io/sentry/Scopes.java | 2 +- .../io/sentry/NoOpContinuousProfilerTest.kt | 2 +- sentry/src/test/java/io/sentry/ScopesTest.kt | 2 +- 17 files changed, 53 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d1497bffe0..7df988a3684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Continuous Profiling - stop when app goes in background ([#4311](https://github.com/getsentry/sentry-java/pull/4311)) - Continuous Profiling - Add delayed stop ([#4293](https://github.com/getsentry/sentry-java/pull/4293)) - Continuous Profiling - Out of Experimental ([#4310](https://github.com/getsentry/sentry-java/pull/4310)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 3f31b51e646..c2b313b8e83 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -42,7 +42,7 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android public class io/sentry/android/core/AndroidContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { public fun (Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)V - public fun close ()V + public fun close (Z)V public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun getRootSpanCounter ()I public fun isRunning ()Z diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index 94be555104d..f3e8088760d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -322,12 +322,15 @@ public void reevaluateSampling() { shouldSample = true; } - public void close() { + @Override + public void close(final boolean isTerminating) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { rootSpanCounter = 0; shouldStop = true; - stop(false); - isClosed.set(true); + if (isTerminating) { + stop(false); + isClosed.set(true); + } } } 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 8230157a753..657e6d369ae 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 @@ -268,7 +268,7 @@ private static void setupProfiler( // This is a safeguard, but it should never happen, as the app start profiler should be the // continuous one. if (appStartContinuousProfiler != null) { - appStartContinuousProfiler.close(); + appStartContinuousProfiler.close(true); } if (appStartTransactionProfiler != null) { options.setTransactionProfiler(appStartTransactionProfiler); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index d83ecdd6752..89d78193207 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -122,6 +122,7 @@ public void run() { scopes.endSession(); } scopes.getOptions().getReplayController().stop(); + scopes.getOptions().getContinuousProfiler().close(false); } }; 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 4b569cba6a8..3c162aab1ad 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 @@ -95,7 +95,7 @@ public void shutdown() { final @Nullable IContinuousProfiler appStartContinuousProfiler = AppStartMetrics.getInstance().getAppStartContinuousProfiler(); if (appStartContinuousProfiler != null) { - appStartContinuousProfiler.close(); + appStartContinuousProfiler.close(true); } } } 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 0cc7cdca8e2..562c8949914 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 @@ -225,7 +225,7 @@ public void clear() { } appStartProfiler = null; if (appStartContinuousProfiler != null) { - appStartContinuousProfiler.close(); + appStartContinuousProfiler.close(true); } appStartContinuousProfiler = null; appStartSamplingDecision = null; @@ -333,7 +333,7 @@ private void checkCreateTimeOnMain() { appStartProfiler = null; } if (appStartContinuousProfiler != null && appStartContinuousProfiler.isRunning()) { - appStartContinuousProfiler.close(); + appStartContinuousProfiler.close(true); appStartContinuousProfiler = null; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index 4e1b45ebb02..db0964f15f4 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt @@ -389,7 +389,7 @@ class AndroidContinuousProfilerTest { profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) - profiler.close() + profiler.close(true) assertFalse(profiler.isRunning) // The timeout scheduled job should be cleared @@ -470,6 +470,23 @@ class AndroidContinuousProfilerTest { verify(fixture.scopes).captureProfileChunk(any()) } + @Test + fun `close without terminating stops all profiles after chunk is finished`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + // We are scheduling the profiler to stop at the end of the chunk, so it should still be running + profiler.close(false) + assertTrue(profiler.isRunning) + // However, close() already resets the rootSpanCounter + assertEquals(0, profiler.rootSpanCounter) + + // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart + fixture.executor.runAll() + assertFalse(profiler.isRunning) + } + @Test fun `profiler does not send chunks after close`() { val profiler = fixture.getSut() @@ -477,7 +494,7 @@ class AndroidContinuousProfilerTest { assertTrue(profiler.isRunning) // We close the profiler, which should prevent sending additional chunks - profiler.close() + profiler.close(true) // The executor used to send the chunk doesn't do anything fixture.executor.runAll() 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 dfc88fa0e78..e5a2395feb3 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 @@ -446,7 +446,7 @@ class AndroidOptionsInitializerTest { assertEquals(fixture.sentryOptions.continuousProfiler, NoOpContinuousProfiler.getInstance()) // app start profiler is closed, because it will never be used - verify(appStartContinuousProfiler).close() + verify(appStartContinuousProfiler).close(eq(true)) // AppStartMetrics should be cleared assertNull(AppStartMetrics.getInstance().appStartProfiler) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 5f088221b9d..c1862b41f7b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -3,6 +3,7 @@ package io.sentry.android.core import androidx.lifecycle.LifecycleOwner import io.sentry.Breadcrumb import io.sentry.DateUtils +import io.sentry.IContinuousProfiler import io.sentry.IScope import io.sentry.IScopes import io.sentry.ReplayController @@ -15,6 +16,7 @@ import io.sentry.transport.ICurrentDateProvider import org.mockito.ArgumentCaptor import org.mockito.kotlin.any import org.mockito.kotlin.check +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.timeout @@ -38,6 +40,7 @@ class LifecycleWatcherTest { val dateProvider = mock() val options = SentryOptions() val replayController = mock() + val continuousProfiler = mock() fun getSUT( sessionIntervalMillis: Long = 0L, @@ -52,6 +55,7 @@ class LifecycleWatcherTest { argumentCaptor.value.run(scope) } options.setReplayController(replayController) + options.setContinuousProfiler(continuousProfiler) whenever(scopes.options).thenReturn(options) return LifecycleWatcher( @@ -106,6 +110,7 @@ class LifecycleWatcherTest { watcher.onStop(fixture.ownerMock) verify(fixture.scopes, timeout(10000)).endSession() verify(fixture.replayController, timeout(10000)).stop() + verify(fixture.continuousProfiler, timeout(10000)).close(eq(false)) } @Test 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 f8734a26f00..114ea06d2d2 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 @@ -16,6 +16,7 @@ import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess import org.junit.Before import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -273,7 +274,7 @@ class AppStartMetricsTest { // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() - verify(profiler).close() + verify(profiler).close(eq(true)) } @Test @@ -301,7 +302,7 @@ class AppStartMetricsTest { // Job on main thread checks if activity was launched Shadows.shadowOf(Looper.getMainLooper()).idle() - verify(profiler, never()).close() + verify(profiler, never()).close(any()) } @Test diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 15fb6dadffd..067fa327ed4 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -747,7 +747,7 @@ public abstract interface class io/sentry/IConnectionStatusProvider$IConnectionS } public abstract interface class io/sentry/IContinuousProfiler { - public abstract fun close ()V + public abstract fun close (Z)V public abstract fun getProfilerId ()Lio/sentry/protocol/SentryId; public abstract fun isRunning ()Z public abstract fun reevaluateSampling ()V @@ -1439,7 +1439,7 @@ public final class io/sentry/NoOpConnectionStatusProvider : io/sentry/IConnectio } public final class io/sentry/NoOpContinuousProfiler : io/sentry/IContinuousProfiler { - public fun close ()V + public fun close (Z)V public static fun getInstance ()Lio/sentry/NoOpContinuousProfiler; public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun isRunning ()Z diff --git a/sentry/src/main/java/io/sentry/IContinuousProfiler.java b/sentry/src/main/java/io/sentry/IContinuousProfiler.java index f423401c5d6..3abca9822aa 100644 --- a/sentry/src/main/java/io/sentry/IContinuousProfiler.java +++ b/sentry/src/main/java/io/sentry/IContinuousProfiler.java @@ -14,8 +14,12 @@ void startProfiler( void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle); - /** Cancel the profiler and stops it. Used on SDK close. */ - void close(); + /** + * Cancel the profiler and stops it. + * + * @param isTerminating whether the profiler is terminating and won't be restarted or not. + */ + void close(final boolean isTerminating); void reevaluateSampling(); diff --git a/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java b/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java index 35bb0db5f05..893eb914ad9 100644 --- a/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java +++ b/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java @@ -27,7 +27,7 @@ public void startProfiler( final @NotNull TracesSampler tracesSampler) {} @Override - public void close() {} + public void close(final boolean isTerminating) {} @Override public void reevaluateSampling() {} diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index b2c1bb1aaec..002043c3dfc 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -405,7 +405,7 @@ public void close(final boolean isRestarting) { configureScope(ScopeType.ISOLATION, scope -> scope.clear()); getOptions().getBackpressureMonitor().close(); getOptions().getTransactionProfiler().close(); - getOptions().getContinuousProfiler().close(); + getOptions().getContinuousProfiler().close(true); getOptions().getCompositePerformanceCollector().close(); final @NotNull ISentryExecutorService executorService = getOptions().getExecutorService(); if (isRestarting) { diff --git a/sentry/src/test/java/io/sentry/NoOpContinuousProfilerTest.kt b/sentry/src/test/java/io/sentry/NoOpContinuousProfilerTest.kt index de2dc7e4c48..559190004b8 100644 --- a/sentry/src/test/java/io/sentry/NoOpContinuousProfilerTest.kt +++ b/sentry/src/test/java/io/sentry/NoOpContinuousProfilerTest.kt @@ -24,7 +24,7 @@ class NoOpContinuousProfilerTest { @Test fun `close does not throw`() = - profiler.close() + profiler.close(true) @Test fun `getProfilerId returns Empty SentryId`() { diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index d61ae59f60c..ebb84162240 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -1826,7 +1826,7 @@ class ScopesTest { verify(backpressureMonitorMock).close() verify(executor).close(any()) verify(profiler).close() - verify(continuousProfiler).close() + verify(continuousProfiler).close(eq(true)) verify(performanceCollector).close() } From 5c6c11bde7914466071080e09cac82c9f8d955a1 Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 8 Apr 2025 13:46:46 +0200 Subject: [PATCH 016/846] updated changelog with UI Profiling notes (#4322) * updated changelog with UI Profiling notes --- CHANGELOG.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df988a3684..847b9fb8186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,52 @@ ### Features -- Continuous Profiling - stop when app goes in background ([#4311](https://github.com/getsentry/sentry-java/pull/4311)) -- Continuous Profiling - Add delayed stop ([#4293](https://github.com/getsentry/sentry-java/pull/4293)) -- Continuous Profiling - Out of Experimental ([#4310](https://github.com/getsentry/sentry-java/pull/4310)) +- UI Profiling GA + + Continuous Profiling is now GA, named UI Profiling. To enable it you can use one of the following options. More info can be found at https://docs.sentry.io/platforms/android/profiling/. + Note: Both `options.profilesSampler` and `options.profilesSampleRate` must **not** be set to enable UI Profiling. + To keep the same transaction-based behaviour, without the 30 seconds limitation, you can use the `trace` lifecycle mode. + + ```xml + + + + + + + + + ``` + ```java + import io.sentry.ProfileLifecycle; + import io.sentry.android.core.SentryAndroid; + + SentryAndroid.init(context, options -> { + // Enable UI profiling, adjust in production env. This is evaluated only once per session + options.setProfileSessionSampleRate(1.0); + // Set profiling lifecycle, can be `manual` (controlled through `Sentry.startProfiler()` and `Sentry.stopProfiler()`) or `trace` (automatically starts and stop a profile whenever a sampled trace starts and finishes) + options.setProfileLifecycle(ProfileLifecycle.TRACE); + // Enable profiling on app start. The app start profile will be stopped automatically when the app start root span finishes + options.setStartProfilerOnAppStart(true); + }); + ``` + ```kotlin + import io.sentry.ProfileLifecycle + import io.sentry.android.core.SentryAndroid + + SentryAndroid.init(context, { options -> + // Enable UI profiling, adjust in production env. This is evaluated only once per session + options.profileSessionSampleRate = 1.0 + // Set profiling lifecycle, can be `manual` (controlled through `Sentry.startProfiler()` and `Sentry.stopProfiler()`) or `trace` (automatically starts and stop a profile whenever a sampled trace starts and finishes) + options.profileLifecycle = ProfileLifecycle.TRACE + // Enable profiling on app start. The app start profile will be stopped automatically when the app start root span finishes + options.isStartProfilerOnAppStart = true + }) + ``` + + - Continuous Profiling - Stop when app goes in background ([#4311](https://github.com/getsentry/sentry-java/pull/4311)) + - Continuous Profiling - Add delayed stop ([#4293](https://github.com/getsentry/sentry-java/pull/4293)) + - Continuous Profiling - Out of Experimental ([#4310](https://github.com/getsentry/sentry-java/pull/4310)) ### Fixes From e4bf535a081e6d6795747b14ef5c68b77f08bb56 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 8 Apr 2025 13:33:19 +0000 Subject: [PATCH 017/846] release: 8.7.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 847b9fb8186..93eb8844e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.7.0 ### Features diff --git a/gradle.properties b/gradle.properties index 58de88ec42b..1284adef41d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ org.gradle.workers.max=2 android.useAndroidX=true # Release information -versionName=8.6.0 +versionName=8.7.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 286b57fc510738c02d2f06b237c7ecad307baad9 Mon Sep 17 00:00:00 2001 From: Aleksei Sazonov Date: Wed, 9 Apr 2025 12:37:52 +0300 Subject: [PATCH 018/846] Use thread context classloader when available (#4320) * Use thread context classloader when available * ./gradlew spotlessApply * changelog * Update CHANGELOG.md --------- Co-authored-by: lcian Co-authored-by: Lorenzo Cian --- CHANGELOG.md | 7 +++++++ sentry/src/main/java/io/sentry/util/ClassLoaderUtils.java | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93eb8844e3e..c774f3da7b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixes + +- Use thread context classloader when available ([#4320](https://github.com/getsentry/sentry-java/pull/4320)) + - This ensures correct resource loading in environments like Spring Boot where the thread context classloader is used for resource loading. + ## 8.7.0 ### Features diff --git a/sentry/src/main/java/io/sentry/util/ClassLoaderUtils.java b/sentry/src/main/java/io/sentry/util/ClassLoaderUtils.java index e0a069630ab..d995c1184bd 100644 --- a/sentry/src/main/java/io/sentry/util/ClassLoaderUtils.java +++ b/sentry/src/main/java/io/sentry/util/ClassLoaderUtils.java @@ -8,6 +8,13 @@ public final class ClassLoaderUtils { public static @NotNull ClassLoader classLoaderOrDefault(final @Nullable ClassLoader classLoader) { // bootstrap classloader is represented as null, so using system classloader instead if (classLoader == null) { + // try thread context classloader + final @Nullable ClassLoader contextClassLoader = + Thread.currentThread().getContextClassLoader(); + if (contextClassLoader != null) { + return contextClassLoader; + } + // fallback to system classloader return ClassLoader.getSystemClassLoader(); } else { return classLoader; From f80b9e89f7e27dc83f375818fb784f73cd6913a0 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 10 Apr 2025 10:05:12 +0200 Subject: [PATCH 019/846] Fix do not initialize SDK for Jetpack Compose Preview builds (#4324) * Fix do not initialize SDK for Jetpack Compose Preview builds * Update Changelog --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 1 + .../io/sentry/android/core/ContextUtils.java | 32 ++++++++++++++++++ .../android/core/SentryInitProvider.java | 4 ++- .../sentry/android/core/ContextUtilsTest.kt | 33 +++++++++++++++++++ .../android/core/SentryInitProviderTest.kt | 21 ++++++++++++ 6 files changed, 91 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c774f3da7b5..380b653eff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Use thread context classloader when available ([#4320](https://github.com/getsentry/sentry-java/pull/4320)) - This ensures correct resource loading in environments like Spring Boot where the thread context classloader is used for resource loading. +- Fix do not initialize SDK for Jetpack Compose Preview builds ([#4324](https://github.com/getsentry/sentry-java/pull/4324)) ## 8.7.0 diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index c2b313b8e83..fe36d2c1746 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -185,6 +185,7 @@ public final class io/sentry/android/core/BuildInfoProvider { } public final class io/sentry/android/core/ContextUtils { + public static fun appIsLibraryForComposePreview (Landroid/content/Context;)Z public static fun getApplicationContext (Landroid/content/Context;)Landroid/content/Context; public static fun isForegroundImportance ()Z } 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 e945df2aa9c..0f1c63ef278 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 @@ -7,6 +7,7 @@ import android.annotation.SuppressLint; import android.app.ActivityManager; import android.content.BroadcastReceiver; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; @@ -27,6 +28,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -285,6 +287,36 @@ public static boolean isForegroundImportance() { return isForegroundImportance.getValue(); } + /** + * Determines if the app is a packaged android library for running Compose Preview Mode + * + * @param context the context + * @return true, if the app is actually a library running as an app for Compose Preview Mode + */ + @ApiStatus.Internal + public static boolean appIsLibraryForComposePreview(final @NotNull Context context) { + // Jetpack Compose Preview (aka "Run Preview on Device") + // uses the androidTest flavor for android library modules, + // so let's fail-fast by checking this first + if (context.getPackageName().endsWith(".test")) { + try { + final @NotNull ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + final @NotNull List appTasks = activityManager.getAppTasks(); + for (final ActivityManager.AppTask task : appTasks) { + final @Nullable ComponentName component = task.getTaskInfo().baseIntent.getComponent(); + if (component != null + && component.getClassName().equals("androidx.compose.ui.tooling.PreviewActivity")) { + return true; + } + } + } catch (Throwable t) { + // ignored + } + } + return false; + } + /** * Get the device's current kernel version, as a string. Attempts to read /proc/version, and falls * back to the 'os.version' System Property. diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryInitProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryInitProvider.java index 6d88bdad631..749eda07efc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryInitProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryInitProvider.java @@ -21,7 +21,9 @@ public boolean onCreate() { logger.log(SentryLevel.FATAL, "App. Context from ContentProvider is null"); return false; } - if (ManifestMetadataReader.isAutoInit(context, logger)) { + + if (ManifestMetadataReader.isAutoInit(context, logger) + && !ContextUtils.appIsLibraryForComposePreview(context)) { SentryAndroid.init(context, logger); SentryIntegrationPackageStorage.getInstance().addIntegration("AutoInit"); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt index ea58171d0cf..8a6c9090f04 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt @@ -5,7 +5,9 @@ import android.app.ActivityManager import android.app.ActivityManager.MemoryInfo import android.app.ActivityManager.RunningAppProcessInfo import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context +import android.content.Intent import android.content.IntentFilter import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo @@ -270,4 +272,35 @@ class ContextUtilsTest { val appContext = ContextUtils.getApplicationContext(contextMock) assertSame(appContextMock, appContext) } + + @Test + fun `appIsLibraryForComposePreview is correctly determined`() { + fun getMockContext( + packageName: String, + activityClassName: String + ): Context { + val context = mock() + val activityManager = mock() + whenever(context.packageName).thenReturn(packageName) + whenever(context.getSystemService(eq(Context.ACTIVITY_SERVICE))).thenReturn( + activityManager + ) + val taskInfo = ActivityManager.RecentTaskInfo() + taskInfo.baseIntent = Intent().setComponent( + ComponentName( + "com.example.library", + activityClassName + ) + ) + val appTask = mock() + whenever(appTask.taskInfo).thenReturn(taskInfo) + whenever(activityManager.appTasks).thenReturn(listOf(appTask)) + + return context + } + + assertTrue(ContextUtils.appIsLibraryForComposePreview(getMockContext("com.example.library.test", "androidx.compose.ui.tooling.PreviewActivity"))) + assertFalse(ContextUtils.appIsLibraryForComposePreview(getMockContext("com.example.library.test", "com.example.HomeActivity"))) + assertFalse(ContextUtils.appIsLibraryForComposePreview(getMockContext("com.example.library", "androidx.compose.ui.tooling.PreviewActivity"))) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt index 927e8792376..d3f78c7cb88 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt @@ -7,11 +7,14 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Sentry import io.sentry.test.callMethod import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.any import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.use @RunWith(AndroidJUnit4::class) class SentryInitProviderTest { @@ -153,6 +156,24 @@ class SentryInitProviderTest { assertFalse(sentryOptions.isEnableNdk) } + @Test + fun `skips init in compose preview mode`() { + val providerInfo = ProviderInfo() + + assertFalse(Sentry.isEnabled()) + providerInfo.authority = AUTHORITY + + val metaData = Bundle() + metaData.putString(ManifestMetadataReader.DSN, "https://key@sentry.io/123") + val mockContext = ContextUtilsTestHelper.mockMetaData(metaData = metaData) + + Mockito.mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils.`when` { ContextUtils.appIsLibraryForComposePreview(any()) }.thenReturn(true) + sentryInitProvider.attachInfo(mockContext, providerInfo) + } + assertFalse(Sentry.isEnabled()) + } + companion object { private const val AUTHORITY = "io.sentry.sample.SentryInitProvider" } From df386efdc9b34ee04f3cb3164c4385c762a05fa4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 10 Apr 2025 10:41:38 +0200 Subject: [PATCH 020/846] fix(breadcrumbs): Improve low memory breadcrumb capturing (#4325) * Improve low memory breadcrumb capturing * Changelog * Debounce low memory breadcrumbs --- CHANGELOG.md | 1 + .../AppComponentsBreadcrumbsIntegration.java | 52 +++++++++++-------- ...AppComponentsBreadcrumbsIntegrationTest.kt | 44 +++++++++------- 3 files changed, 57 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 380b653eff8..049b2a818da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Use thread context classloader when available ([#4320](https://github.com/getsentry/sentry-java/pull/4320)) - This ensures correct resource loading in environments like Spring Boot where the thread context classloader is used for resource loading. +- Improve low memory breadcrumb capturing ([#4325](https://github.com/getsentry/sentry-java/pull/4325)) - Fix do not initialize SDK for Jetpack Compose Preview builds ([#4324](https://github.com/getsentry/sentry-java/pull/4324)) ## 8.7.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java index ade16a329ed..196c9f32205 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java @@ -12,6 +12,8 @@ import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.internal.util.Debouncer; import io.sentry.android.core.internal.util.DeviceOrientations; import io.sentry.protocol.Device; import io.sentry.util.Objects; @@ -24,10 +26,17 @@ public final class AppComponentsBreadcrumbsIntegration implements Integration, Closeable, ComponentCallbacks2 { + private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; + // pre-allocate hint to avoid creating it every time for the low memory case + private static final @NotNull Hint EMPTY_HINT = new Hint(); + private final @NotNull Context context; private @Nullable IScopes scopes; private @Nullable SentryAndroidOptions options; + private final @NotNull Debouncer trimMemoryDebouncer = + new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0); + public AppComponentsBreadcrumbsIntegration(final @NotNull Context context) { this.context = Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); @@ -91,42 +100,43 @@ public void onConfigurationChanged(@NotNull Configuration newConfig) { @Override public void onLowMemory() { - final long now = System.currentTimeMillis(); - executeInBackground(() -> captureLowMemoryBreadcrumb(now, null)); + // we do this in onTrimMemory below already, this is legacy API (14 or below) } @Override public void onTrimMemory(final int level) { + if (level < TRIM_MEMORY_BACKGROUND) { + // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or + // TRIM_MEMORY_COMPLETE. + // Release as much memory as the process can. + + // TRIM_MEMORY_UI_HIDDEN, TRIM_MEMORY_RUNNING_MODERATE, TRIM_MEMORY_RUNNING_LOW and + // TRIM_MEMORY_RUNNING_CRITICAL. + // Release any memory that your app doesn't need to run. + // So they are still not so critical at the point of killing the process. + // https://developer.android.com/topic/performance/memory + return; + } + + if (trimMemoryDebouncer.checkForDebounce()) { + // if we received trim_memory within 1 minute time, ignore this call + return; + } + final long now = System.currentTimeMillis(); executeInBackground(() -> captureLowMemoryBreadcrumb(now, level)); } - private void captureLowMemoryBreadcrumb(final long timeMs, final @Nullable Integer level) { + private void captureLowMemoryBreadcrumb(final long timeMs, final int level) { if (scopes != null) { final Breadcrumb breadcrumb = new Breadcrumb(timeMs); - if (level != null) { - // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or - // TRIM_MEMORY_COMPLETE. - // Release as much memory as the process can. - - // TRIM_MEMORY_UI_HIDDEN, TRIM_MEMORY_RUNNING_MODERATE, TRIM_MEMORY_RUNNING_LOW and - // TRIM_MEMORY_RUNNING_CRITICAL. - // Release any memory that your app doesn't need to run. - // So they are still not so critical at the point of killing the process. - // https://developer.android.com/topic/performance/memory - - if (level < TRIM_MEMORY_BACKGROUND) { - return; - } - breadcrumb.setData("level", level); - } - breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); breadcrumb.setMessage("Low memory"); breadcrumb.setData("action", "LOW_MEMORY"); + breadcrumb.setData("level", level); breadcrumb.setLevel(SentryLevel.WARNING); - scopes.addBreadcrumb(breadcrumb); + scopes.addBreadcrumb(breadcrumb, EMPTY_HINT); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt index 9ae0c1c1c0f..4d3e6caa816 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt @@ -15,6 +15,7 @@ import org.mockito.kotlin.check import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import java.lang.NullPointerException import kotlin.test.Test @@ -95,24 +96,6 @@ class AppComponentsBreadcrumbsIntegrationTest { sut.close() } - @Test - fun `When low memory event, a breadcrumb with type, category and level should be set`() { - val sut = fixture.getSut() - val options = SentryAndroidOptions().apply { - executorService = ImmediateExecutorService() - } - val scopes = mock() - sut.register(scopes, options) - sut.onLowMemory() - verify(scopes).addBreadcrumb( - check { - assertEquals("device.event", it.category) - assertEquals("system", it.type) - assertEquals(SentryLevel.WARNING, it.level) - } - ) - } - @Test fun `When trim memory event with level, a breadcrumb with type, category and level should be set`() { val sut = fixture.getSut() @@ -127,7 +110,8 @@ class AppComponentsBreadcrumbsIntegrationTest { assertEquals("device.event", it.category) assertEquals("system", it.type) assertEquals(SentryLevel.WARNING, it.level) - } + }, + anyOrNull() ) } @@ -162,4 +146,26 @@ class AppComponentsBreadcrumbsIntegrationTest { anyOrNull() ) } + + @Test + fun `low memory changes are debounced`() { + val sut = fixture.getSut() + + val scopes = mock() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + sut.register(scopes, options) + sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) + sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) + + // should only add the first crumb + verify(scopes).addBreadcrumb( + check { + assertEquals(it.data["level"], 40) + }, + anyOrNull() + ) + verifyNoMoreInteractions(scopes) + } } From 49517e1718a352a953665597a87966cd28192390 Mon Sep 17 00:00:00 2001 From: Lorenzo Cian Date: Fri, 11 Apr 2025 14:34:59 +0200 Subject: [PATCH 021/846] Add `CoroutineExceptionHandler` (#4259) * Add CoroutineExceptionHandler * tests * changelog * improve * improve * Update CHANGELOG.md --- CHANGELOG.md | 7 ++ buildSrc/src/main/java/Config.kt | 3 + sentry-apollo-4/build.gradle.kts | 2 +- .../api/sentry-kotlin-extensions.api | 7 ++ sentry-kotlin-extensions/build.gradle.kts | 1 + .../kotlin/SentryCoroutineExceptionHandler.kt | 31 +++++++ .../SentryCoroutineExceptionHandlerTest.kt | 80 +++++++++++++++++++ .../sentry-samples-android/build.gradle.kts | 3 + .../sentry/samples/android/CoroutinesUtil.kt | 16 ++++ .../sentry/samples/android/MainActivity.java | 5 ++ .../src/main/res/layout/activity_main.xml | 6 ++ .../src/main/res/values/strings.xml | 1 + 12 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryCoroutineExceptionHandler.kt create mode 100644 sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryCoroutineExceptionHandlerTest.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CoroutinesUtil.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 049b2a818da..662aab793a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Features + +- Add `CoroutineExceptionHandler` for reporting uncaught exceptions in coroutines to Sentry ([#4259](https://github.com/getsentry/sentry-java/pull/4259)) + - This is now part of `sentry-kotlin-extensions` and can be used together with `SentryContext` when launching a coroutine + - Any exceptions thrown in a coroutine when using the handler will be captured (not rethrown!) and reported to Sentry + - It's also possible to extend `CoroutineExceptionHandler` to implement custom behavior in addition to the one we provide by default + ### Fixes - Use thread context classloader when available ([#4320](https://github.com/getsentry/sentry-java/pull/4320)) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index d5f2e5029df..b379a389d82 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -121,6 +121,8 @@ object Config { val coroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1" + val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1" + val fragment = "androidx.fragment:fragment-ktx:1.3.5" val reactorCore = "io.projectreactor:reactor-core:3.5.3" @@ -214,6 +216,7 @@ object Config { 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" + val coroutinesTest = "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1" } object QualityPlugins { diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 6e8c292966b..e591c7ec0c6 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { testImplementation(Config.TestLibs.mockitoInline) testImplementation(Config.TestLibs.mockWebserver) testImplementation(Config.Libs.apolloKotlin4) - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation(Config.TestLibs.coroutinesTest) testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0") } diff --git a/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api b/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api index 0555383c1b1..e11ec192499 100644 --- a/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api +++ b/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api @@ -10,3 +10,10 @@ public final class io/sentry/kotlin/SentryContext : kotlin/coroutines/AbstractCo public synthetic fun updateThreadContext (Lkotlin/coroutines/CoroutineContext;)Ljava/lang/Object; } +public class io/sentry/kotlin/SentryCoroutineExceptionHandler : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/CoroutineExceptionHandler { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public synthetic fun (Lio/sentry/IScopes;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun handleException (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)V +} + diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index c8aa448e511..6d66ed250a9 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation(Config.TestLibs.mockitoKotlin) testImplementation(Config.Libs.coroutinesCore) + testImplementation(Config.TestLibs.coroutinesTest) } configure { diff --git a/sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryCoroutineExceptionHandler.kt b/sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryCoroutineExceptionHandler.kt new file mode 100644 index 00000000000..9fc4a23d2e1 --- /dev/null +++ b/sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryCoroutineExceptionHandler.kt @@ -0,0 +1,31 @@ +package io.sentry.kotlin + +import io.sentry.IScopes +import io.sentry.ScopesAdapter +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.exception.ExceptionMechanismException +import io.sentry.protocol.Mechanism +import kotlinx.coroutines.CoroutineExceptionHandler +import org.jetbrains.annotations.ApiStatus +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +/** + * Captures exceptions thrown in coroutines (without rethrowing them) and reports them to Sentry as errors. + */ +@ApiStatus.Experimental +public open class SentryCoroutineExceptionHandler(private val scopes: IScopes = ScopesAdapter.getInstance()) : + AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { + + override fun handleException(context: CoroutineContext, exception: Throwable) { + val mechanism = Mechanism().apply { + type = "CoroutineExceptionHandler" + } + // the current thread is not necessarily the one that threw the exception + val error = ExceptionMechanismException(mechanism, exception, Thread.currentThread()) + val event = SentryEvent(error) + event.level = SentryLevel.ERROR + scopes.captureEvent(event) + } +} diff --git a/sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryCoroutineExceptionHandlerTest.kt b/sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryCoroutineExceptionHandlerTest.kt new file mode 100644 index 00000000000..1f21105dcf1 --- /dev/null +++ b/sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryCoroutineExceptionHandlerTest.kt @@ -0,0 +1,80 @@ +package io.sentry.kotlin + +import io.sentry.IScopes +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SentryCoroutineExceptionHandlerTest { + + class Fixture { + val scopes = mock() + + fun getSut(): SentryCoroutineExceptionHandler { + return SentryCoroutineExceptionHandler(scopes) + } + } + + @Test + fun `captures unhandled exception in launch coroutine`() = runTest { + val fixture = Fixture() + val handler = fixture.getSut() + val exception = RuntimeException("test") + + GlobalScope.launch(handler) { + throw exception + }.join() + + verify(fixture.scopes).captureEvent( + check { + assertSame(exception, it.throwable) + } + ) + } + + @Test + fun `captures unhandled exception in launch coroutine with child`() = runTest { + val fixture = Fixture() + val handler = fixture.getSut() + val exception = RuntimeException("test") + + GlobalScope.launch(handler) { + launch { + throw exception + }.join() + }.join() + + verify(fixture.scopes).captureEvent( + check { + assertSame(exception, it.throwable) + } + ) + } + + @Test + fun `captures unhandled exception in async coroutine`() = runTest { + val fixture = Fixture() + val handler = fixture.getSut() + val exception = RuntimeException("test") + + val deferred = GlobalScope.async() { + throw exception + } + GlobalScope.launch(handler) { + deferred.await() + }.join() + + verify(fixture.scopes).captureEvent( + check { + assertTrue { exception.toString().equals(it.throwable.toString()) } // stack trace will differ + } + ) + } +} diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index cba0dfd2d77..4af4e0eac26 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -150,5 +150,8 @@ dependencies { implementation(Config.Libs.composeCoil) implementation(Config.Libs.sentryNativeNdk) + implementation(projects.sentryKotlinExtensions) + implementation(Config.Libs.coroutinesAndroid) + debugImplementation(Config.Libs.leakCanary) } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CoroutinesUtil.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CoroutinesUtil.kt new file mode 100644 index 00000000000..6e43574819d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CoroutinesUtil.kt @@ -0,0 +1,16 @@ +package io.sentry.samples.android + +import io.sentry.kotlin.SentryContext +import io.sentry.kotlin.SentryCoroutineExceptionHandler +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.lang.RuntimeException + +object CoroutinesUtil { + + fun throwInCoroutine() { + GlobalScope.launch(SentryContext() + SentryCoroutineExceptionHandler()) { + throw RuntimeException("Exception in coroutine") + } + } +} 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 a4085bf8225..802e765a9e4 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 @@ -270,6 +270,11 @@ public void run() { binding.openFrameDataForSpans.setOnClickListener( view -> startActivity(new Intent(this, FrameDataForSpansActivity.class))); + binding.throwInCoroutine.setOnClickListener( + view -> { + CoroutinesUtil.INSTANCE.throwInCoroutine(); + }); + setContentView(binding.getRoot()); } 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 b8a47c6bd59..71c8059d588 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 @@ -148,6 +148,12 @@ android:layout_height="wrap_content" android:text="@string/open_frame_data_for_spans"/> +