Skip to content

Commit a4c401e

Browse files
authored
Capture click events for compose (#1002)
* compose instrumentation * add tests, README.md and update dependency * add consumer rules * add instrumentation to demo app * refactor * make everything internal per pr feedback and fix a bit of a blunder * move to click package and remove version pinning
1 parent 44f68be commit a4c401e

17 files changed

Lines changed: 1234 additions & 1 deletion

File tree

demo-app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ dependencies {
6666

6767
coreLibraryDesugaring(libs.desugarJdkLibs)
6868

69+
implementation("io.opentelemetry.android:instrumentation-compose")
6970
implementation("io.opentelemetry.android:android-agent") //parent dir
7071
implementation("io.opentelemetry.android:instrumentation-sessions")
7172
implementation(libs.androidx.core.ktx)

demo-app/settings.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ dependencyResolutionManagement {
2222

2323
includeBuild("..") {
2424
dependencySubstitution {
25+
substitute(module("io.opentelemetry.android:instrumentation-compose"))
26+
.using(project(":instrumentation:compose"))
2527
substitute(module("io.opentelemetry.android:android-agent"))
2628
.using(project(":android-agent"))
2729
substitute(module("io.opentelemetry.android:instrumentation-sessions"))

demo-app/src/main/java/io/opentelemetry/android/demo/MainOtelButton.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import androidx.compose.runtime.Composable
1717
import androidx.compose.ui.Modifier
1818
import androidx.compose.ui.graphics.Color
1919
import androidx.compose.ui.graphics.painter.Painter
20+
import androidx.compose.ui.semantics.onClick
21+
import androidx.compose.ui.semantics.semantics
2022
import androidx.compose.ui.unit.dp
2123
import io.opentelemetry.api.metrics.LongCounter
2224
import io.opentelemetry.api.trace.SpanKind
@@ -28,7 +30,9 @@ fun MainOtelButton(icon: Painter,
2830
Spacer(modifier = Modifier.height(5.dp))
2931
Button(
3032
onClick = { generateClickEvent(clickCounter) },
31-
modifier = Modifier.padding(20.dp),
33+
modifier = Modifier.padding(20.dp).semantics{
34+
onClick("MainOtelButton") { true }
35+
},
3236
colors = ButtonDefaults.buttonColors(containerColor = Color.Black),
3337
content = {
3438
Image(

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ androidPlugin = "8.10.1"
1414
junitKtx = "1.2.1"
1515
autoService = "1.1.1"
1616
androidx-navigation = "2.7.7"
17+
compose = "1.5.4"
1718

1819
[libraries]
1920
opentelemetry-platform-alpha = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha", version.ref = "opentelemetry-instrumentation-alpha" }
@@ -43,6 +44,7 @@ opentelemetry-exporter-otlp = { module = "io.opentelemetry:opentelemetry-exporte
4344
volley = "com.android.volley:volley:1.2.1"
4445
auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" }
4546
auto-service-processor = { module = "com.google.auto.service:auto-service", version.ref = "autoService" }
47+
compose = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" }
4648

4749
#Test tools
4850
opentelemetry-sdk-testing = { module = "io.opentelemetry:opentelemetry-sdk-testing" }
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
2+
# Compose Instrumentation
3+
4+
Status: development
5+
6+
## Compose version
7+
`1.3.0` to `1.5.4`
8+
9+
This instrumentation has the ability to generate events when the user
10+
performs click actions. A click is not differentiated from touch or other
11+
input pointer events.
12+
13+
When an Activity becomes active, the instrumentation begins tracking
14+
its window by registering a callback that receives events.
15+
16+
This instrumentation is not currently enabled by default.
17+
18+
## Telemetry
19+
20+
Data produced by this instrumentation will have an instrumentation scope
21+
name of `io.opentelemetry.android.instrumentation.compose.click`.
22+
This instrumentation produces the following telemetry:
23+
24+
### Clicks
25+
26+
* Type: Event
27+
* Name: `app.screen.click`
28+
* Description: This event is emitted when the user taps or clicks on the screen.
29+
* See the [semantic convention definition](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/app/app.md#event-appscreenclick)
30+
for more details.
31+
32+
* Type: Event
33+
* Name: `event.app.widget.click`
34+
* Description: This event is emitted when the user taps on a composable that is clickable.
35+
* See the [semantic convention definition](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/app/app.md#event-appwidgetclick)
36+
for more details.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
plugins {
2+
id("otel.android-library-conventions")
3+
id("otel.publish-conventions")
4+
}
5+
6+
description = "OpenTelemetry Android compose click instrumentation"
7+
8+
android {
9+
namespace = "io.opentelemetry.android.instrumentation.compose.click"
10+
11+
defaultConfig {
12+
consumerProguardFiles("consumer-rules.pro")
13+
}
14+
}
15+
16+
dependencies {
17+
api(project(":services"))
18+
api(libs.opentelemetry.api)
19+
api(platform(libs.opentelemetry.platform.alpha))
20+
api(project(":instrumentation:android-instrumentation"))
21+
22+
compileOnly(libs.compose)
23+
implementation(libs.opentelemetry.api.incubator)
24+
implementation(libs.opentelemetry.instrumentation.apiSemconv)
25+
implementation(libs.opentelemetry.semconv.incubating)
26+
27+
testImplementation(project(":test-common"))
28+
testImplementation(project(":session"))
29+
30+
testImplementation(libs.compose)
31+
testImplementation(libs.robolectric)
32+
testImplementation(libs.androidx.test.core)
33+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Keep the Compose internals class name. We need this for compose click capture.
2+
-keep class androidx.compose.foundation.ClickableElement {
3+
<fields>;
4+
}
5+
-keep class androidx.compose.foundation.CombinedClickableElement {
6+
<fields>;
7+
}
8+
-keepnames class androidx.compose.foundation.selection.ToggleableElement
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.compose.click
7+
8+
import android.app.Activity
9+
import io.opentelemetry.android.internal.services.visiblescreen.activities.DefaultingActivityLifecycleCallbacks
10+
11+
internal class ComposeClickActivityCallback(
12+
private val composeClickEventGenerator: ComposeClickEventGenerator,
13+
) : DefaultingActivityLifecycleCallbacks {
14+
override fun onActivityResumed(activity: Activity) {
15+
composeClickEventGenerator.startTracking(activity.window)
16+
}
17+
18+
override fun onActivityPaused(activity: Activity) {
19+
composeClickEventGenerator.stopTracking()
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
7+
8+
package io.opentelemetry.instrumentation.compose.click
9+
10+
import android.view.MotionEvent
11+
import android.view.Window
12+
import androidx.compose.ui.node.LayoutNode
13+
import io.opentelemetry.api.common.Attributes
14+
import io.opentelemetry.api.incubator.logs.ExtendedLogRecordBuilder
15+
import io.opentelemetry.api.incubator.logs.ExtendedLogger
16+
import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_SCREEN_COORDINATE_X
17+
import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_SCREEN_COORDINATE_Y
18+
import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_WIDGET_ID
19+
import io.opentelemetry.semconv.incubating.AppIncubatingAttributes.APP_WIDGET_NAME
20+
import java.lang.ref.WeakReference
21+
22+
internal class ComposeClickEventGenerator(
23+
private val eventLogger: ExtendedLogger,
24+
private val composeLayoutNodeUtil: ComposeLayoutNodeUtil = ComposeLayoutNodeUtil(),
25+
private val composeTapTargetDetector: ComposeTapTargetDetector = ComposeTapTargetDetector(composeLayoutNodeUtil),
26+
) {
27+
private var windowRef: WeakReference<Window>? = null
28+
29+
fun startTracking(window: Window) {
30+
windowRef = WeakReference(window)
31+
val currentCallback = window.callback
32+
window.callback = WindowCallbackWrapper(currentCallback, this)
33+
}
34+
35+
fun generateClick(motionEvent: MotionEvent?) {
36+
windowRef?.get()?.let { window ->
37+
if (motionEvent != null && motionEvent.actionMasked == MotionEvent.ACTION_UP) {
38+
createEvent(APP_SCREEN_CLICK_EVENT_NAME)
39+
.setAttribute(APP_SCREEN_COORDINATE_Y, motionEvent.y.toLong())
40+
.setAttribute(APP_SCREEN_COORDINATE_X, motionEvent.x.toLong())
41+
.emit()
42+
43+
composeTapTargetDetector.findTapTarget(window.decorView, motionEvent.x, motionEvent.y)?.let { layoutNode ->
44+
createEvent(VIEW_CLICK_EVENT_NAME)
45+
.setAllAttributes(createNodeAttributes(layoutNode))
46+
.emit()
47+
}
48+
}
49+
}
50+
}
51+
52+
fun stopTracking() {
53+
windowRef?.get()?.run {
54+
if (callback is WindowCallbackWrapper) {
55+
callback = (callback as WindowCallbackWrapper).unwrap()
56+
}
57+
}
58+
windowRef = null
59+
}
60+
61+
private fun createEvent(name: String): ExtendedLogRecordBuilder =
62+
eventLogger
63+
.logRecordBuilder()
64+
.setEventName(name)
65+
66+
private fun createNodeAttributes(node: LayoutNode): Attributes {
67+
val builder = Attributes.builder()
68+
builder.put(APP_WIDGET_NAME, composeTapTargetDetector.nodeToName(node))
69+
builder.put(APP_WIDGET_ID, node.semanticsId.toString())
70+
71+
composeLayoutNodeUtil.getLayoutNodePositionInWindow(node)?.let {
72+
builder.put(APP_SCREEN_COORDINATE_X, it.x.toLong())
73+
builder.put(APP_SCREEN_COORDINATE_Y, it.y.toLong())
74+
}
75+
return builder.build()
76+
}
77+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.compose.click
7+
8+
import com.google.auto.service.AutoService
9+
import io.opentelemetry.android.instrumentation.AndroidInstrumentation
10+
import io.opentelemetry.android.instrumentation.InstallationContext
11+
import io.opentelemetry.api.incubator.logs.ExtendedLogger
12+
13+
@AutoService(AndroidInstrumentation::class)
14+
class ComposeClickInstrumentation : AndroidInstrumentation {
15+
override val name: String = "compose.click"
16+
17+
override fun install(ctx: InstallationContext) {
18+
ctx.application.registerActivityLifecycleCallbacks(
19+
ComposeClickActivityCallback(
20+
ComposeClickEventGenerator(
21+
ctx.openTelemetry
22+
.logsBridge
23+
.loggerBuilder("io.opentelemetry.android.instrumentation.compose.click")
24+
.build() as ExtendedLogger,
25+
),
26+
),
27+
)
28+
}
29+
}

0 commit comments

Comments
 (0)