Skip to content

Commit

Permalink
feat: add render instrumentation (#20)
Browse files Browse the repository at this point in the history
## Which problem is this PR solving?
Adds manual instrumentation for rendering times of Composables in
Jetpack Compose

## Short description of the changes
heavily inspired by
honeycombio/honeycomb-opentelemetry-swift#20


Multiple re-renders do get nested under a single "Created" Span, but
otherwise the spans look identical to the iOS spans:


![image](https://github.com/user-attachments/assets/50460236-9649-4264-b457-b4be9d1eca62)

## How to verify that this has the expected result
- [x] traces appear in honeycomb with the correct values (see screenshot
above)
- [x] smoke tests
  • Loading branch information
MustafaHaddara authored and beekhc committed Jan 14, 2025
1 parent 3b21688 commit 86307eb
Show file tree
Hide file tree
Showing 8 changed files with 307 additions and 14 deletions.
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,51 @@ These events may have the following attributes.
* `view.id.package` - The package for the XML ID of the view.
* `view.name` - The "best" available name of the view, given the other identifiers. Usually the same as `view.id.entry`.

## Manual Instrumentation

### Android Compose
#### Setup
Initialize the `Honeycomb` sdk, and then wrap your entire app in a `CompositionLocalProvider` that provides `LocalOpenTelemetryRum`, as so:

```kotlin
import io.honeycomb.opentelemetry.android.LocalOpenTelemetryRum

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val app = application as ExampleApp
val otelRum = app.otelRum

setContent {
CompositionLocalProvider(LocalOpenTelemetryRum provides otelRum) {
// app content
}
}
}
}
```

#### Usage
Wrap your SwiftUI views with `HoneycombInstrumentedComposable(name: String)`, like so:

```kotlin
@Composable
private fun MyComposable() {
HoneycombInstrumentedComposable("main view") {
// ...
}
}
```

This will measure and emit instrumentation for your Composable's render times, ex:

Specifically, it will emit 2 kinds of span for each composable that is wrapped:

`View Render` spans encompass the entire rendering process, from initialization to appearing on screen. They include the following attributes:
- `view.name` (string): the name passed to `HoneycombInstrumentedComposable`
- `view.renderDuration` (double): amount of time in seconds to spent initializing the contents of `HoneycombInstrumentedComposable`
- `view.totalDuration` (double): amount of time in seconds from when the contents of `HoneycombInstrumentedComposable` start initializing to when the contents appear on screen

`View Body` spans encompass just the contents of the `HoneycombInstrumentedView`, and include the following attributes:
- `view.name` (string): the name passed to `HoneycombInstrumentedComposable`
8 changes: 8 additions & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ android {
}
buildFeatures {
buildConfig = true
compose = true
}
buildTypes {
release {
Expand All @@ -40,6 +41,9 @@ android {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
kotlinOptions {
jvmTarget = "1.8"
}
Expand All @@ -57,6 +61,10 @@ android {
}

dependencies {
implementation(libs.androidx.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))

// This is required by opentelemetry-android.
coreLibraryDesugaring(libs.desugar.jdk.libs)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package io.honeycomb.opentelemetry.android

import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import io.opentelemetry.android.OpenTelemetryRum
import java.time.Instant
import kotlin.time.DurationUnit
import kotlin.time.TimeSource.Monotonic.markNow

private const val TAG = "HoneycombInstrumentedCo" // max 23 characters :sob:

/**
* Heavily inspired by https://github.com/theapache64/boil/blob/master/files/LogComposition.kt
*/
@Composable
fun HoneycombInstrumentedComposable(
name: String,
composable: @Composable (() -> Unit),
) {
if (LocalOpenTelemetryRum.current == null) {
Log.w(TAG, "No LocalOpenTelemetryRum provided!")

composable()
return
}

val otelRum = LocalOpenTelemetryRum.current!!.openTelemetry
val tracer = otelRum.tracerProvider.tracerBuilder("io.honeycomb.render-instrumentation").build()
val span =
tracer
.spanBuilder("View Render")
.setAttribute("view.name", name)
.startSpan()

span.makeCurrent().use {
val bodySpan =
tracer
.spanBuilder("View Body")
.setAttribute("view.name", name)
.startSpan()

bodySpan.makeCurrent().use {
val start = markNow()
composable()
val endTime = Instant.now()

val bodyDuration = start.elapsedNow()
// bodyDuration is in seconds
// calling duration.inWholeSeconds would lose precision
span.setAttribute("view.renderDuration", bodyDuration.toDouble(DurationUnit.SECONDS))

SideEffect {
bodySpan.end(endTime)
val renderDuration = start.elapsedNow()
span.setAttribute("view.totalDuration", renderDuration.toDouble(DurationUnit.SECONDS))
span.end()
}
}
}
}

val LocalOpenTelemetryRum = compositionLocalOf<OpenTelemetryRum?> { null }
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package io.honeycomb.opentelemetry.android.example

import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.text.intl.Locale
Expand Down Expand Up @@ -104,4 +107,18 @@ class HoneycombSmokeTest {
val backButton: UiObject2? = device.findObject(buttonSelector("Back"))
backButton!!.clickAndWait(Until.newWindow(), 1000)
}

@Test
fun renderInstrumentation_works() {
rule.onNodeWithText("Render").performClick()
rule.onNodeWithTag("slow_render_switch").performClick()

rule.waitUntil(5000) {
rule.onAllNodesWithText("slow text", true).assertCountEquals(5)

true
}

rule.onNodeWithText("Core").performClick()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,19 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Straighten
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Straighten
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
Expand All @@ -33,6 +36,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.honeycomb.opentelemetry.android.LocalOpenTelemetryRum
import io.honeycomb.opentelemetry.android.example.ui.theme.HoneycombOpenTelemetryAndroidTheme
import io.opentelemetry.android.OpenTelemetryRum

Expand All @@ -47,6 +51,7 @@ enum class PlaygroundTab(
CORE("Core", Icons.Outlined.Home, Icons.Filled.Home),
UI("UI", Icons.Outlined.Palette, Icons.Filled.Palette),
NETWORK("Network", Icons.Outlined.Language, Icons.Filled.Language),
VIEW_INSTRUMENTATION("Render", Icons.Outlined.Straighten, Icons.Filled.Straighten),
}

/**
Expand All @@ -63,16 +68,18 @@ class MainActivity : ComponentActivity() {
setContent {
val currentTab = remember { mutableStateOf(PlaygroundTab.CORE) }

HoneycombOpenTelemetryAndroidTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = { NavBar(currentTab) },
) { innerPadding ->
Playground(
otelRum,
currentTab,
modifier = Modifier.padding(innerPadding),
)
CompositionLocalProvider(LocalOpenTelemetryRum provides otelRum) {
HoneycombOpenTelemetryAndroidTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = { NavBar(currentTab) },
) { innerPadding ->
Playground(
otelRum,
currentTab,
modifier = Modifier.padding(innerPadding),
)
}
}
}
}
Expand All @@ -89,7 +96,10 @@ fun Playground(
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.fillMaxSize().padding(20.dp),
modifier =
modifier
.fillMaxSize()
.padding(20.dp),
) {
Text(
text =
Expand All @@ -103,12 +113,15 @@ fun Playground(
PlaygroundTab.CORE -> {
CorePlayground(otel)
}
PlaygroundTab.UI -> {
UIPlayground()
}
PlaygroundTab.NETWORK -> {
NetworkPlayground()
}
PlaygroundTab.VIEW_INSTRUMENTATION -> {
ViewInstrumentationPlayground()
}
PlaygroundTab.UI -> {
UIPlayground()
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package io.honeycomb.opentelemetry.android.example

import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import io.honeycomb.opentelemetry.android.HoneycombInstrumentedComposable
import io.honeycomb.opentelemetry.android.example.ui.theme.HoneycombOpenTelemetryAndroidTheme
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit

private const val TAG = "ViewInstrumentation"

@Composable
private fun NestedExpensiveView(delay: Duration) {
Row {
HoneycombInstrumentedComposable("nested expensive text") {
Text(text = timeConsumingCalculation(delay))
}
}
}

@Composable
private fun DelayedSlider(
delay: Long,
onValueChange: (Duration) -> Unit,
) {
val (sliderDelay, setSliderDelay) = remember { mutableFloatStateOf(delay.toFloat()) }
Slider(
value = sliderDelay,
onValueChange = setSliderDelay,
onValueChangeFinished = { onValueChange(sliderDelay.toLong().milliseconds) },
valueRange = 0f..4000f,
steps = 7,
)
}

@Composable
private fun ExpensiveView() {
val (delay, setDelay) = remember { mutableStateOf(1000L.milliseconds) }

HoneycombInstrumentedComposable("main view") {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth(),
) {
DelayedSlider(delay = delay.toLong(DurationUnit.MILLISECONDS), onValueChange = setDelay)

HoneycombInstrumentedComposable("expensive text 1") {
Text(text = timeConsumingCalculation(delay))
}

HoneycombInstrumentedComposable("expensive text 2") {
Text(text = timeConsumingCalculation(delay))
}

HoneycombInstrumentedComposable("expensive text 3") {
Text(text = timeConsumingCalculation(delay))
}

HoneycombInstrumentedComposable("nested expensive view") {
NestedExpensiveView(delay = delay)
}

HoneycombInstrumentedComposable("expensive text 4") {
Text(text = timeConsumingCalculation(delay))
}
}
}
}

@Composable
internal fun ViewInstrumentationPlayground() {
val (enabled, setEnabled) = remember { mutableStateOf(false) }

Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = "enable slow render")
Switch(
checked = enabled,
onCheckedChange = setEnabled,
modifier = Modifier.testTag("slow_render_switch"),
)
}
if (enabled) {
ExpensiveView()
}
}
}

private fun timeConsumingCalculation(delay: Duration): String {
Log.d(TAG, "starting time consuming calculation")
Thread.sleep(delay.toLong(DurationUnit.MILLISECONDS))
return "slow text: ${delay.toDouble(DurationUnit.SECONDS)} seconds"
}

@Preview(showBackground = true)
@Composable
fun ViewInstrumentationPlaygroundPreview() {
HoneycombOpenTelemetryAndroidTheme {
ViewInstrumentationPlayground()
}
}
Loading

0 comments on commit 86307eb

Please sign in to comment.