diff --git a/instrumentation/compose/click/api/click.api b/instrumentation/compose/click/api/click.api index acbdd1d59..a13f447ab 100644 --- a/instrumentation/compose/click/api/click.api +++ b/instrumentation/compose/click/api/click.api @@ -9,3 +9,7 @@ public final class io/opentelemetry/instrumentation/compose/click/ComposeLayoutN public static final field VIEW_CLICK_EVENT_NAME Ljava/lang/String; } +public final class io/opentelemetry/instrumentation/compose/click/OpentelemetryModifierKt { + public static final fun opentelemetry (Landroidx/compose/ui/Modifier;Ljava/lang/String;)Landroidx/compose/ui/Modifier; +} + diff --git a/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetector.kt b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetector.kt index dfa3ad705..8a0e200f2 100644 --- a/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetector.kt +++ b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetector.kt @@ -67,17 +67,15 @@ internal class ComposeTapTargetDetector( ): LayoutNode? { val queue = LinkedList() queue.addFirst(owner.root) - var target: LayoutNode? = null - while (queue.isNotEmpty()) { val node = queue.removeFirst() if (node.isPlaced && hitTest(node, x, y)) { - target = node + return node } queue.addAll(node.zSortedChildren.asMutableList()) } - return target + return null } private fun isValidClickTarget(node: LayoutNode): Boolean { @@ -85,6 +83,9 @@ internal class ComposeTapTargetDetector( val modifier = info.modifier if (modifier is SemanticsModifier) { with(modifier.semanticsConfiguration) { + if (contains(OpentelemetrySemanticsPropertyKey)) { + return true + } if (contains(SemanticsActions.OnClick)) { return true } @@ -110,6 +111,11 @@ internal class ComposeTapTargetDetector( val modifier = info.modifier if (modifier is SemanticsModifier) { with(modifier.semanticsConfiguration) { + val opentelemetrySemanticsPropertyKey = getOrNull(OpentelemetrySemanticsPropertyKey) + if (!opentelemetrySemanticsPropertyKey.isNullOrBlank()) { + return opentelemetrySemanticsPropertyKey + } + val onClickSemanticsConfiguration = getOrNull(SemanticsActions.OnClick) if (onClickSemanticsConfiguration != null) { val accessibilityActionLabel = onClickSemanticsConfiguration.label @@ -131,6 +137,10 @@ internal class ComposeTapTargetDetector( } else { className = modifier::class.qualifiedName } + val testTag = modifier.getTestTag() + if (testTag != null) { + return testTag + } } return className diff --git a/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/OpentelemetryModifier.kt b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/OpentelemetryModifier.kt new file mode 100644 index 000000000..a21466e5d --- /dev/null +++ b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/OpentelemetryModifier.kt @@ -0,0 +1,33 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.compose.click + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics + +/** + * An opentelemetry Modifier that allows to mark a composable element as traceable. + * When a composable element that has this Modifier will be tapped, then the [name] from this + * Modifier will be taken as the element name + * + * @param name name of the tapped element + */ +fun Modifier.opentelemetry(name: String): Modifier = + this.semantics { + this.opentelemetry = name + } + +internal val OpentelemetrySemanticsPropertyKey: SemanticsPropertyKey = + SemanticsPropertyKey( + name = "_opentelemetry_semantics", + mergePolicy = { parentValue, _ -> + parentValue + }, + ) + +private var SemanticsPropertyReceiver.opentelemetry by OpentelemetrySemanticsPropertyKey diff --git a/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/TestTagFinder.kt b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/TestTagFinder.kt new file mode 100644 index 000000000..37b95fe52 --- /dev/null +++ b/instrumentation/compose/click/src/main/kotlin/io/opentelemetry/instrumentation/compose/click/TestTagFinder.kt @@ -0,0 +1,42 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.compose.click + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import kotlin.jvm.java +import kotlin.text.isNullOrEmpty + +private const val TEST_TAG_FIELD_NAME = "tag" + +internal fun Modifier.getTestTag(): String? = findTestTagInModifier(this) + +internal fun findTestTagInModifier(modifier: Modifier): String? { + if (modifier is SemanticsModifier) { + with(modifier.semanticsConfiguration) { + val testTag = getOrNull(SemanticsProperties.TestTag) + if (!testTag.isNullOrEmpty()) { + return testTag + } + } + } + // Often the Modifier is a TestTagElement. As this class is private there is only a way to + // get the TestTag value using reflection + if ("androidx.compose.ui.platform.TestTagElement" == modifier::class.qualifiedName) { + try { + val testTagField = modifier::class.java.getDeclaredField(TEST_TAG_FIELD_NAME) + testTagField.isAccessible = true + val testTag = testTagField.get(modifier) as String + if (testTag.isNotEmpty()) { + return testTag + } + } catch (_: Exception) { + } + } + return null +} diff --git a/instrumentation/compose/click/src/test/kotlin/androidx/compose/ui/platform/TestTagElement.kt b/instrumentation/compose/click/src/test/kotlin/androidx/compose/ui/platform/TestTagElement.kt new file mode 100644 index 000000000..89ada949f --- /dev/null +++ b/instrumentation/compose/click/src/test/kotlin/androidx/compose/ui/platform/TestTagElement.kt @@ -0,0 +1,14 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package androidx.compose.ui.platform + +import androidx.compose.ui.Modifier + +// Test-only stand-in for the private Compose TestTagElement. +// Must have a private field named `tag` so reflection in the production code finds it. +class TestTagElement( + @Suppress("UnusedPrivateProperty") private val tag: String, +) : Modifier.Element diff --git a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickEventGeneratorTest.kt b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickEventGeneratorTest.kt index 9aa7d5f56..1253447be 100644 --- a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickEventGeneratorTest.kt +++ b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeClickEventGeneratorTest.kt @@ -235,6 +235,8 @@ internal class ComposeClickEventGeneratorTest { every { semanticsModifier.semanticsConfiguration } returns semanticsConfiguration every { semanticsConfiguration.contains(eq(SemanticsActions.OnClick)) } returns true + every { semanticsConfiguration.contains(eq(OpentelemetrySemanticsPropertyKey)) } returns false + every { semanticsConfiguration.getOrNull(eq(OpentelemetrySemanticsPropertyKey)) } returns null if (useDescription) { every { semanticsConfiguration.getOrNull(eq(SemanticsActions.OnClick)) } returns null @@ -284,7 +286,7 @@ internal class ComposeClickEventGeneratorTest { } every { nodeList[0].zSortedChildren } returns mutableVectorOf(nodeList[1], nodeList[2]) - every { nodeList[1].zSortedChildren } returns mutableVectorOf(nodeList[4], nodeList[3]) + every { nodeList[1].zSortedChildren } returns mutableVectorOf(nodeList[3], nodeList[4]) every { nodeList[2].zSortedChildren } returns mutableVectorOf() every { nodeList[3].zSortedChildren } returns mutableVectorOf() diff --git a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeInstrumentationTest.kt b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeInstrumentationTest.kt index 31b4bd3b7..d1e423b37 100644 --- a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeInstrumentationTest.kt +++ b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeInstrumentationTest.kt @@ -41,7 +41,6 @@ import io.mockk.slot import io.mockk.verify import io.opentelemetry.android.instrumentation.InstallationContext import io.opentelemetry.android.session.SessionProvider -import io.opentelemetry.sdk.logs.data.LogRecordData import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo import io.opentelemetry.sdk.testing.junit4.OpenTelemetryRule @@ -98,6 +97,74 @@ internal class ComposeInstrumentationTest { @Test fun capture_compose_click() { + val motionEvent = + MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + val mockLayoutNode = + createMockLayoutNode( + targetX = motionEvent.x, + targetY = motionEvent.y, + hit = true, + clickable = true, + useDescription = true, + ) + mockComposeClick(mockLayoutNode, motionEvent) + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] + assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), + ) + + event = events[1] + assertThat(event) + .hasEventName(VIEW_CLICK_EVENT_NAME) + .hasAttributesSatisfying( + equalTo(APP_WIDGET_ID, mockLayoutNode.semanticsId.toString()), + equalTo(APP_WIDGET_NAME, "clickMe"), + ) + } + + @Test + fun capture_compose_click_using_modifier() { + val motionEvent = + MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) + val mockLayoutNode = + createMockLayoutNode( + targetX = motionEvent.x, + targetY = motionEvent.y, + hit = true, + clickable = true, + useOpenTelemetryModifier = true, + ) + mockComposeClick(mockLayoutNode, motionEvent) + val events = openTelemetryRule.logRecords + assertThat(events).hasSize(2) + + var event = events[0] + assertThat(event) + .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) + .hasAttributesSatisfyingExactly( + equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), + equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), + ) + + event = events[1] + assertThat(event) + .hasEventName(VIEW_CLICK_EVENT_NAME) + .hasAttributesSatisfying( + equalTo(APP_WIDGET_ID, mockLayoutNode.semanticsId.toString()), + equalTo(APP_WIDGET_NAME, "opentelemetryClick"), + ) + } + + private fun mockComposeClick( + mockLayoutNode: LayoutNode, + motionEvent: MotionEvent, + ) { val installationContext = InstallationContext( application, @@ -122,19 +189,9 @@ internal class ComposeInstrumentationTest { val wrapperCapturingSlot = slot() every { window.callback = any() } returns Unit - val motionEvent = - MotionEvent.obtain(0L, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 250f, 50f, 0) every { window.decorView } returns composeView every { composeView.childCount } returns 0 - val mockLayoutNode = - createMockLayoutNode( - targetX = motionEvent.x, - targetY = motionEvent.y, - hit = true, - clickable = true, - useDescription = true, - ) every { composeView.root } returns mockLayoutNode viewClickActivityCallback.onActivityResumed(activity) @@ -145,25 +202,6 @@ internal class ComposeInstrumentationTest { wrapperCapturingSlot.captured.dispatchTouchEvent( motionEvent, ) - - val events = openTelemetryRule.logRecords - assertThat(events).hasSize(2) - - var event = events[0] - assertThat(event) - .hasEventName(APP_SCREEN_CLICK_EVENT_NAME) - .hasAttributesSatisfyingExactly( - equalTo(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong()), - equalTo(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong()), - ) - - event = events[1] - assertThat(event) - .hasEventName(VIEW_CLICK_EVENT_NAME) - .hasAttributesSatisfying( - equalTo(APP_WIDGET_ID, mockLayoutNode.semanticsId.toString()), - equalTo(APP_WIDGET_NAME, "clickMe"), - ) } private fun createMockLayoutNode( @@ -174,6 +212,7 @@ internal class ComposeInstrumentationTest { hit: Boolean = false, clickable: Boolean = false, useDescription: Boolean = false, + useOpenTelemetryModifier: Boolean = false, ): LayoutNode { val mockNode = mockkClass(LayoutNode::class) every { mockNode.isPlaced } returns true @@ -200,6 +239,13 @@ internal class ComposeInstrumentationTest { every { modifierInfo.modifier } returns semanticsModifier every { semanticsModifier.semanticsConfiguration } returns semanticsConfiguration + if (useOpenTelemetryModifier) { + every { semanticsConfiguration.contains(eq(OpentelemetrySemanticsPropertyKey)) } returns true + every { semanticsConfiguration.getOrNull(eq(OpentelemetrySemanticsPropertyKey)) } returns "opentelemetryClick" + } else { + every { semanticsConfiguration.contains(eq(OpentelemetrySemanticsPropertyKey)) } returns false + every { semanticsConfiguration.getOrNull(eq(OpentelemetrySemanticsPropertyKey)) } returns null + } every { semanticsConfiguration.contains(eq(SemanticsActions.OnClick)) } returns true if (useDescription) { diff --git a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetectorTest.kt b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetectorTest.kt index 510d6083f..df9ad650b 100644 --- a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetectorTest.kt +++ b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/ComposeTapTargetDetectorTest.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.ModifierInfo import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.platform.AndroidComposeView +import androidx.compose.ui.platform.TestTagElement import androidx.compose.ui.semantics.AccessibilityAction import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsConfiguration @@ -59,7 +60,13 @@ internal class ComposeTapTargetDetectorTest { @Test fun `name from onClick label`() { - val name = composeTapTargetDetector.nodeToName(createMockLayoutNode(clickable = true)) + val name = + composeTapTargetDetector.nodeToName( + createMockLayoutNode( + clickable = true, + useOnClick = true, + ), + ) assertThat(name).isEqualTo("click") } @@ -143,6 +150,44 @@ internal class ComposeTapTargetDetectorTest { assertThat(actual).isNull() } + @Test + fun `name from opentelemetry modifier`() { + val name = + composeTapTargetDetector.nodeToName( + createMockLayoutNode( + clickable = true, + useDescription = true, + useOpenTelemetryModifier = true, + ), + ) + assertThat(name).isEqualTo("opentelemetryClick") + } + + @Test + fun `name from test tag modifier`() { + val name = + composeTapTargetDetector.nodeToName( + createMockLayoutNode( + clickable = true, + useTestTag = true, + ), + ) + assertThat(name).isEqualTo("testTagClick") + } + + @Test + fun `name from test tag element using reflection`() { + val name = + composeTapTargetDetector.nodeToName( + createMockLayoutNode( + clickable = true, + useTestTagWithReflection = true, + ), + ) + assertThat(name).isEqualTo("testTagClick") + } + + @Suppress("LongMethod") private fun createMockLayoutNode( targetX: Float = 0f, targetY: Float = 0f, @@ -151,6 +196,10 @@ internal class ComposeTapTargetDetectorTest { hit: Boolean = false, clickable: Boolean = false, useDescription: Boolean = false, + useOnClick: Boolean = false, + useOpenTelemetryModifier: Boolean = false, + useTestTag: Boolean = false, + useTestTagWithReflection: Boolean = false, ): LayoutNode { val mockNode = mockkClass(LayoutNode::class) every { mockNode.isPlaced } returns true @@ -180,15 +229,34 @@ internal class ComposeTapTargetDetectorTest { every { semanticsModifier.semanticsConfiguration } returns semanticsConfiguration every { semanticsConfiguration.contains(eq(SemanticsActions.OnClick)) } returns true + if (useOpenTelemetryModifier) { + every { semanticsConfiguration.contains(eq(OpentelemetrySemanticsPropertyKey)) } returns true + every { semanticsConfiguration.getOrNull(eq(OpentelemetrySemanticsPropertyKey)) } returns "opentelemetryClick" + } else { + every { semanticsConfiguration.contains(eq(OpentelemetrySemanticsPropertyKey)) } returns false + every { semanticsConfiguration.getOrNull(eq(OpentelemetrySemanticsPropertyKey)) } returns null + } + if (useDescription) { - every { semanticsConfiguration.getOrNull(eq(SemanticsActions.OnClick)) } returns null every { semanticsConfiguration.getOrNull(eq(SemanticsProperties.ContentDescription)) } returns - listOf( - "clickMe", - ) + listOf("clickMe") } else { + every { semanticsConfiguration.getOrNull(eq(SemanticsProperties.ContentDescription)) } returns null + } + + if (useOnClick) { every { semanticsConfiguration.getOrNull(eq(SemanticsActions.OnClick)) } returns AccessibilityAction<() -> Boolean>("click") { true } + } else { + every { semanticsConfiguration.getOrNull(eq(SemanticsActions.OnClick)) } returns null + } + + if (useTestTag) { + every { semanticsConfiguration.getOrNull(eq(SemanticsProperties.TestTag)) } returns "testTagClick" + } else if (useTestTagWithReflection) { + every { mockModifierInfo.modifier } returns TestTagElement("testTagClick") + } else { + every { semanticsConfiguration.getOrNull(eq(SemanticsProperties.TestTag)) } returns null } every { mockNode.semanticsId } returns id @@ -199,11 +267,7 @@ internal class ComposeTapTargetDetectorTest { every { mockNode.zSortedChildren } returns mutableVectorOf() every { composeLayoutNodeUtil.getLayoutNodeBoundsInWindow(mockNode) } returns bounds every { composeLayoutNodeUtil.getLayoutNodePositionInWindow(mockNode) } returns - Offset( - x = bounds.left, - y = bounds.top, - ) - + Offset(x = bounds.left, y = bounds.top) return mockNode } } diff --git a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/OpentelemetryModifierTest.kt b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/OpentelemetryModifierTest.kt new file mode 100644 index 000000000..436e36425 --- /dev/null +++ b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/OpentelemetryModifierTest.kt @@ -0,0 +1,23 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.compose.click + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsModifier +import androidx.compose.ui.semantics.getOrNull +import org.junit.Assert.assertEquals +import org.junit.Test + +class OpentelemetryModifierTest { + @Test + fun opentelemetryModifier_returnsModifiedModifier() { + val modifier = Modifier.opentelemetry("custom name") + with((modifier as SemanticsModifier).semanticsConfiguration) { + val opentelemetryModifierValue = getOrNull(OpentelemetrySemanticsPropertyKey) + assertEquals("custom name", opentelemetryModifierValue) + } + } +} diff --git a/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/TestTagFinderTest.kt b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/TestTagFinderTest.kt new file mode 100644 index 000000000..47ec67b0a --- /dev/null +++ b/instrumentation/compose/click/src/test/kotlin/io/opentelemetry/instrumentation/compose/click/TestTagFinderTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.compose.click + +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.TestTagElement +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.jupiter.api.assertNull + +class TestTagFinderTest { + @Test + fun `finds tag via reflection on TestTagElement`() { + val element = TestTagElement("my-test-tag") + val result = findTestTagInModifier(element as Modifier) + assertEquals("my-test-tag", result) + } + + @Test + fun `finds tag via reflection on TestTagElement with empty value`() { + val element = TestTagElement("") + val result = findTestTagInModifier(element as Modifier) + assertNull(result) + } + + @Test + fun `finds tag via reflection on Modifier`() { + val element = Modifier + val result = findTestTagInModifier(element) + assertNull(result) + } +}