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 {
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
}

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(
@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])
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