Skip to content
4 changes: 4 additions & 0 deletions instrumentation/compose/click/api/click.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be internal to avoid exposing it to anybody using this module, or does it need to be public?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has to be public as this is meant to be used on jetpack compose elements defined by application

public static final fun opentelemetry (Landroidx/compose/ui/Modifier;Ljava/lang/String;)Landroidx/compose/ui/Modifier;
}

Original file line number Diff line number Diff line change
Expand Up @@ -67,24 +67,25 @@ internal class ComposeTapTargetDetector(
): LayoutNode? {
val queue = LinkedList<LayoutNode>()
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
Comment on lines -70 to +78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first glance, this appears to be a semantic change. Previously, the code would keep adding children and walking the queue until it found the "last" one that matched -- which I think means that it potentially got more specific. So was this change intentional, and if so, why?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this change was intentional. For me it seems to be a bug to take the last node.

In my test application when a compose component consists of multiple nodes (i.e. i'm using some UI components from some 3rd party library), this code caused to take the node from the library, but not the node that I define in my application.

In my opinion it should get the top most node.

}

private fun isValidClickTarget(node: LayoutNode): Boolean {
for (info in node.getModifierInfo()) {
val modifier = info.modifier
if (modifier is SemanticsModifier) {
with(modifier.semanticsConfiguration) {
if (contains(OpentelemetrySemanticsPropertyKey)) {
return true
}
if (contains(SemanticsActions.OnClick)) {
return true
}
Expand All @@ -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
Expand All @@ -131,6 +137,10 @@ internal class ComposeTapTargetDetector(
} else {
className = modifier::class.qualifiedName
}
val testTag = modifier.getTestTag()
if (testTag != null) {
return testTag
}
}

return className
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> =
SemanticsPropertyKey(
name = "_opentelemetry_semantics",
mergePolicy = { parentValue, _ ->
parentValue
},
)

private var SemanticsPropertyReceiver.opentelemetry by OpentelemetrySemanticsPropertyKey
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting. Are users supposed to be able to consume this, because I don't think that this would be published in any consumable artifacts. Maybe it should just be in the main src tree, under some test package, rather than test source?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for internal unit test of opentelemetry-android compose-click module

@Suppress("UnusedPrivateProperty") private val tag: String,
) : Modifier.Element
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering what triggered this change because the idea is that 3 is higher than 4 in z-order and swapping them means 4 is now on top and the assertion on lines 194-199 should fail?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me check the unit tests again

every { nodeList[2].zSortedChildren } returns mutableVectorOf()

every { nodeList[3].zSortedChildren } returns mutableVectorOf()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,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,
Expand All @@ -121,19 +189,9 @@ internal class ComposeInstrumentationTest {
val wrapperCapturingSlot = slot<WindowCallbackWrapper>()
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)
Expand All @@ -144,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(
Expand All @@ -173,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
Expand All @@ -199,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) {
Expand Down
Loading