Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -122,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 @@ -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(
Expand All @@ -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
Expand All @@ -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) {
Expand Down
Loading