Skip to content

Commit 550d8ec

Browse files
markushiromtsngetsentry-bot
authored
Add capabilities to track jetpack compose composition/rendering time (#2507)
Co-authored-by: Roman Zavarnitsyn <rom4ek93@gmail.com> Co-authored-by: Sentry Github Bot <bot+github-bot@sentry.io>
1 parent e0df34f commit 550d8ec

File tree

20 files changed

+975
-286
lines changed

20 files changed

+975
-286
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- You can enable it by adding `sentry.enable-tracing=true` to your `application.properties`
1010
- The Spring Boot integration can now be configured to add the `SentryAppender` to specific loggers instead of the `ROOT` logger ([#2173](https://github.com/getsentry/sentry-java/pull/2173))
1111
- You can specify the loggers using `"sentry.logging.loggers[0]=foo.bar` and `"sentry.logging.loggers[1]=baz` in your `application.properties`
12+
- Add capabilities to track Jetpack Compose composition/rendering time ([#2507](https://github.com/getsentry/sentry-java/pull/2507))
1213

1314
### Fixes
1415

sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java

+6-13
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,10 @@ private void startTracing(final @NotNull Activity activity) {
182182
final Boolean coldStart = AppStartState.getInstance().isColdStart();
183183

184184
final TransactionOptions transactionOptions = new TransactionOptions();
185-
185+
if (options.isEnableActivityLifecycleTracingAutoFinish()) {
186+
transactionOptions.setIdleTimeout(options.getIdleTimeout());
187+
transactionOptions.setTrimEnd(true);
188+
}
186189
transactionOptions.setWaitForChildren(true);
187190
transactionOptions.setTransactionFinishedCallback(
188191
(finishingTransaction) -> {
@@ -392,21 +395,11 @@ public synchronized void onActivityResumed(final @NotNull Activity activity) {
392395
mainHandler.post(() -> onFirstFrameDrawn(ttidSpan));
393396
}
394397
addBreadcrumb(activity, "resumed");
395-
396-
// fallback call for API < 29 compatibility, otherwise it happens on onActivityPostResumed
397-
if (!isAllActivityCallbacksAvailable && options != null) {
398-
stopTracing(activity, options.isEnableActivityLifecycleTracingAutoFinish());
399-
}
400398
}
401399

402400
@Override
403-
public synchronized void onActivityPostResumed(final @NotNull Activity activity) {
404-
// only executed if API >= 29 otherwise it happens on onActivityResumed
405-
if (isAllActivityCallbacksAvailable && options != null) {
406-
// this should be called only when onResume has been executed already, which means
407-
// the UI is responsive at this moment.
408-
stopTracing(activity, options.isEnableActivityLifecycleTracingAutoFinish());
409-
}
401+
public void onActivityPostResumed(@NonNull Activity activity) {
402+
// empty override, required to avoid a api-level breaking super.onActivityPostResumed() calls
410403
}
411404

412405
@Override

sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt

+54-25
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import io.sentry.FullyDisplayedReporter
1616
import io.sentry.Hub
1717
import io.sentry.ISentryExecutorService
1818
import io.sentry.Scope
19+
import io.sentry.Sentry
1920
import io.sentry.SentryDate
2021
import io.sentry.SentryLevel
2122
import io.sentry.SentryNanotimeDate
@@ -71,9 +72,23 @@ class ActivityLifecycleIntegrationTest {
7172
lateinit var transaction: SentryTracer
7273
val buildInfo = mock<BuildInfoProvider>()
7374

74-
fun getSut(apiVersion: Int = 29, importance: Int = RunningAppProcessInfo.IMPORTANCE_FOREGROUND): ActivityLifecycleIntegration {
75+
fun getSut(
76+
apiVersion: Int = 29,
77+
importance: Int = RunningAppProcessInfo.IMPORTANCE_FOREGROUND,
78+
initializer: Sentry.OptionsConfiguration<SentryAndroidOptions>? = null
79+
): ActivityLifecycleIntegration {
80+
initializer?.configure(options)
81+
7582
whenever(hub.options).thenReturn(options)
76-
transaction = SentryTracer(context, hub, true, transactionFinishedCallback)
83+
84+
// TODO: we should let the ActivityLifecycleIntegration create the proper transaction here
85+
val transactionOptions = TransactionOptions().apply {
86+
isWaitForChildren = true
87+
if (options.isEnableActivityLifecycleTracingAutoFinish) {
88+
idleTimeout = options.idleTimeout
89+
}
90+
}
91+
transaction = SentryTracer(context, hub, transactionOptions, transactionFinishedCallback)
7792
whenever(hub.startTransaction(any(), any<TransactionOptions>())).thenReturn(transaction)
7893
whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion)
7994

@@ -417,18 +432,32 @@ class ActivityLifecycleIntegrationTest {
417432
}
418433

419434
@Test
420-
fun `When tracing auto finish is enabled and ttid and ttfd spans are finished, it stops the transaction on onActivityPostResumed`() {
421-
val sut = fixture.getSut()
422-
fixture.options.tracesSampleRate = 1.0
423-
fixture.options.isEnableTimeToFullDisplayTracing = true
424-
sut.register(fixture.hub, fixture.options)
425-
435+
fun `When tracing auto finish is enabled and ttid and ttfd spans are finished, it schedules the transaction finish`() {
426436
val activity = mock<Activity>()
437+
val sut = fixture.getSut(initializer = {
438+
it.tracesSampleRate = 1.0
439+
it.isEnableTimeToFullDisplayTracing = true
440+
it.idleTimeout = 200
441+
})
442+
sut.register(fixture.hub, fixture.options)
427443
sut.onActivityCreated(activity, fixture.bundle)
444+
428445
sut.ttidSpanMap.values.first().finish()
429446
sut.ttfdSpan?.finish()
430-
sut.onActivityPostResumed(activity)
431447

448+
// then transaction should not be immediatelly finished
449+
verify(fixture.hub, never())
450+
.captureTransaction(
451+
anyOrNull(),
452+
anyOrNull(),
453+
anyOrNull(),
454+
anyOrNull()
455+
)
456+
457+
// but when idle timeout has passed
458+
Thread.sleep(400)
459+
460+
// then the transaction should be finished
432461
verify(fixture.hub).captureTransaction(
433462
check {
434463
assertEquals(SpanStatus.OK, it.status)
@@ -485,16 +514,16 @@ class ActivityLifecycleIntegrationTest {
485514

486515
@Test
487516
fun `When tracing auto finish is disabled, do not finish transaction`() {
488-
val sut = fixture.getSut()
489-
fixture.options.tracesSampleRate = 1.0
490-
fixture.options.isEnableActivityLifecycleTracingAutoFinish = false
517+
val sut = fixture.getSut(initializer = {
518+
it.tracesSampleRate = 1.0
519+
it.isEnableActivityLifecycleTracingAutoFinish = false
520+
})
491521
sut.register(fixture.hub, fixture.options)
492-
493522
val activity = mock<Activity>()
494523
sut.onActivityCreated(activity, fixture.bundle)
495524
sut.onActivityPostResumed(activity)
496525

497-
verify(fixture.hub, never()).captureTransaction(any(), anyOrNull<TraceContext>(), anyOrNull(), anyOrNull())
526+
verify(fixture.hub, never()).captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull())
498527
}
499528

500529
@Test
@@ -668,37 +697,37 @@ class ActivityLifecycleIntegrationTest {
668697
sut.onActivityCreated(activity, mock())
669698
sut.onActivityResumed(activity)
670699

671-
verify(fixture.hub, never()).captureTransaction(any(), any<TraceContext>(), anyOrNull(), anyOrNull())
700+
verify(fixture.hub, never()).captureTransaction(any(), any(), anyOrNull(), anyOrNull())
672701
}
673702

674703
@Test
675-
fun `start transaction on created if API less than 29`() {
704+
fun `do not stop transaction on resumed if API less than 29 and ttid and ttfd are finished`() {
676705
val sut = fixture.getSut(14)
677706
fixture.options.tracesSampleRate = 1.0
707+
fixture.options.isEnableTimeToFullDisplayTracing = true
678708
sut.register(fixture.hub, fixture.options)
679709

680-
setAppStartTime()
681-
682710
val activity = mock<Activity>()
683711
sut.onActivityCreated(activity, mock())
712+
sut.ttidSpanMap.values.first().finish()
713+
sut.ttfdSpan?.finish()
714+
sut.onActivityResumed(activity)
684715

685-
verify(fixture.hub).startTransaction(any(), any<TransactionOptions>())
716+
verify(fixture.hub, never()).captureTransaction(any(), any(), anyOrNull(), anyOrNull())
686717
}
687718

688719
@Test
689-
fun `stop transaction on resumed if API 29 less than 29 and ttid and ttfd are finished`() {
720+
fun `start transaction on created if API less than 29`() {
690721
val sut = fixture.getSut(14)
691722
fixture.options.tracesSampleRate = 1.0
692-
fixture.options.isEnableTimeToFullDisplayTracing = true
693723
sut.register(fixture.hub, fixture.options)
694724

725+
setAppStartTime()
726+
695727
val activity = mock<Activity>()
696728
sut.onActivityCreated(activity, mock())
697-
sut.ttidSpanMap.values.first().finish()
698-
sut.ttfdSpan?.finish()
699-
sut.onActivityResumed(activity)
700729

701-
verify(fixture.hub).captureTransaction(any(), anyOrNull<TraceContext>(), anyOrNull(), anyOrNull())
730+
verify(fixture.hub).startTransaction(any(), any<TransactionOptions>())
702731
}
703732

704733
@Test
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.sentry.uitest.android
2+
3+
import androidx.lifecycle.Lifecycle
4+
import androidx.test.core.app.launchActivity
5+
import androidx.test.ext.junit.runners.AndroidJUnit4
6+
import io.sentry.Sentry
7+
import io.sentry.SentryLevel
8+
import io.sentry.SentryOptions
9+
import io.sentry.android.core.SentryAndroidOptions
10+
import io.sentry.protocol.SentryTransaction
11+
import org.junit.runner.RunWith
12+
import kotlin.test.Test
13+
import kotlin.test.assertEquals
14+
import kotlin.test.assertTrue
15+
16+
@RunWith(AndroidJUnit4::class)
17+
class AutomaticSpansTest : BaseUiTest() {
18+
19+
@Test
20+
fun ttidTtfdSpans() {
21+
val transactions = mutableListOf<SentryTransaction>()
22+
23+
initSentry(false) { options: SentryAndroidOptions ->
24+
options.isDebug = true
25+
options.setDiagnosticLevel(SentryLevel.DEBUG)
26+
options.tracesSampleRate = 1.0
27+
options.profilesSampleRate = 1.0
28+
options.isEnableAutoActivityLifecycleTracing = true
29+
options.beforeSendTransaction
30+
options.isEnableTimeToFullDisplayTracing = true
31+
options.beforeSendTransaction =
32+
SentryOptions.BeforeSendTransactionCallback { transaction, _ ->
33+
transactions.add(transaction)
34+
transaction
35+
}
36+
}
37+
38+
val activity = launchActivity<ComposeActivity>()
39+
activity.moveToState(Lifecycle.State.RESUMED)
40+
activity.onActivity {
41+
Sentry.reportFullyDisplayed()
42+
}
43+
activity.moveToState(Lifecycle.State.DESTROYED)
44+
45+
assertEquals(1, transactions.size)
46+
assertTrue("TTID span missing") {
47+
transactions.first().spans.any {
48+
it.op == "ui.load.initial_display"
49+
}
50+
}
51+
assertTrue("TTFD span missing") {
52+
transactions.first().spans.any {
53+
it.op == "ui.load.full_display"
54+
}
55+
}
56+
}
57+
}

sentry-compose/api/android/sentry-compose.api

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ public final class io/sentry/compose/BuildConfig {
66
public fun <init> ()V
77
}
88

9+
public final class io/sentry/compose/SentryComposeTracingKt {
10+
public static final fun SentryTraced (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V
11+
}
12+
913
public final class io/sentry/compose/SentryNavigationIntegrationKt {
1014
public static final fun withSentryObservableEffect (Landroidx/navigation/NavHostController;ZZLandroidx/compose/runtime/Composer;II)Landroidx/navigation/NavHostController;
1115
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package io.sentry.compose
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.BoxScope
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.Immutable
7+
import androidx.compose.runtime.compositionLocalOf
8+
import androidx.compose.runtime.remember
9+
import androidx.compose.ui.ExperimentalComposeUiApi
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.draw.drawWithContent
12+
import androidx.compose.ui.platform.testTag
13+
import io.sentry.ISpan
14+
import io.sentry.Sentry
15+
import io.sentry.SpanOptions
16+
17+
private const val OP_PARENT_COMPOSITION = "ui.compose.composition"
18+
private const val OP_COMPOSE = "ui.compose"
19+
20+
private const val OP_PARENT_RENDER = "ui.compose.rendering"
21+
private const val OP_RENDER = "ui.render"
22+
23+
@Immutable
24+
private class ImmutableHolder<T>(var item: T)
25+
26+
private fun getRootSpan(): ISpan? {
27+
var rootSpan: ISpan? = null
28+
Sentry.configureScope {
29+
rootSpan = it.transaction
30+
}
31+
return rootSpan
32+
}
33+
34+
private val localSentryCompositionParentSpan = compositionLocalOf {
35+
ImmutableHolder(
36+
getRootSpan()
37+
?.startChild(
38+
OP_PARENT_COMPOSITION,
39+
"Jetpack Compose Initial Composition",
40+
SpanOptions().apply {
41+
isTrimStart = true
42+
isTrimEnd = true
43+
isIdle = true
44+
}
45+
)
46+
)
47+
}
48+
49+
private val localSentryRenderingParentSpan = compositionLocalOf {
50+
ImmutableHolder(
51+
getRootSpan()
52+
?.startChild(
53+
OP_PARENT_RENDER,
54+
"Jetpack Compose Initial Render",
55+
SpanOptions().apply {
56+
isTrimStart = true
57+
isTrimEnd = true
58+
isIdle = true
59+
}
60+
)
61+
)
62+
}
63+
64+
@ExperimentalComposeUiApi
65+
@Composable
66+
public fun SentryTraced(
67+
tag: String,
68+
modifier: Modifier = Modifier,
69+
enableUserInteractionTracing: Boolean = true,
70+
content: @Composable BoxScope.() -> Unit
71+
) {
72+
val parentCompositionSpan = localSentryCompositionParentSpan.current
73+
val parentRenderingSpan = localSentryRenderingParentSpan.current
74+
val compositionSpan = parentCompositionSpan.item?.startChild(OP_COMPOSE, tag)
75+
val firstRendered = remember { ImmutableHolder(false) }
76+
77+
val baseModifier = if (enableUserInteractionTracing) modifier.testTag(tag) else modifier
78+
79+
Box(
80+
modifier = baseModifier
81+
.drawWithContent {
82+
val renderSpan = if (!firstRendered.item) {
83+
parentRenderingSpan.item?.startChild(
84+
OP_RENDER,
85+
tag
86+
)
87+
} else {
88+
null
89+
}
90+
drawContent()
91+
firstRendered.item = true
92+
renderSpan?.finish()
93+
}
94+
) {
95+
content()
96+
}
97+
compositionSpan?.finish()
98+
}

0 commit comments

Comments
 (0)