Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### Fixes

- Fix fragment tracing not working with detach/attach navigation ([#5660](https://github.com/getsentry/sentry-java/pull/5660))
- Don't start a redundant UI interaction transaction when a transaction is already bound to the Scope ([#5491](https://github.com/getsentry/sentry-java/issues/5491))
- Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children.
- Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,7 @@ public class SentryFragmentLifecycleCallbacks(
) {
addBreadcrumb(fragment, FragmentLifecycleState.CREATED)

// we only start the tracing for the fragment if the fragment has been added to its activity
// and not only to the backstack
if (fragment.isAdded) {
if (scopes.options.isEnableScreenTracking) {
scopes.configureScope { it.screen = getFragmentName(fragment) }
}
startTracing(fragment)
}
startTracing(fragment)
}

override fun onFragmentViewCreated(
Expand All @@ -93,17 +86,30 @@ public class SentryFragmentLifecycleCallbacks(
savedInstanceState: Bundle?,
) {
addBreadcrumb(fragment, FragmentLifecycleState.VIEW_CREATED)

// For detach/attach navigation (e.g. manual tab switching, ViewPager v1 with
// FragmentPagerAdapter, custom navigation frameworks), onFragmentCreated is never called for
// off-screen fragments that are re-attached. Starting here enables a narrower
// "view created -> resumed" span for those paths. startTracing is idempotent, so for the
// normal onFragmentCreated -> onFragmentViewCreated path this is a no-op.
Comment thread
romtsn marked this conversation as resolved.
startTracing(fragment)
}

override fun onFragmentStarted(fragmentManager: FragmentManager, fragment: Fragment) {
addBreadcrumb(fragment, FragmentLifecycleState.STARTED)

// ViewPager2 locks background fragments to STARTED state
// ViewPager2 locks background fragments to STARTED state, so we stop here to avoid
// spans hanging for off-screen fragments that never reach RESUMED.
stopTracing(fragment)
}

override fun onFragmentResumed(fragmentManager: FragmentManager, fragment: Fragment) {
addBreadcrumb(fragment, FragmentLifecycleState.RESUMED)

// For detach/attach navigation, onFragmentStarted may not fire before onFragmentResumed.
// If a span is still running here, stop it now. stopTracing is idempotent, so this is a
// no-op for the normal path where onFragmentStarted already stopped the span.
stopTracing(fragment)
}

override fun onFragmentPaused(fragmentManager: FragmentManager, fragment: Fragment) {
Expand All @@ -116,6 +122,10 @@ public class SentryFragmentLifecycleCallbacks(

override fun onFragmentViewDestroyed(fragmentManager: FragmentManager, fragment: Fragment) {
addBreadcrumb(fragment, FragmentLifecycleState.VIEW_DESTROYED)

// Failsafe: cancel any span that didn't finish via the normal started/resumed path
// (e.g. fragment view destroyed before reaching STARTED or RESUMED).
stopTracing(fragment)
}

override fun onFragmentDestroyed(fragmentManager: FragmentManager, fragment: Fragment) {
Expand Down Expand Up @@ -153,14 +163,23 @@ public class SentryFragmentLifecycleCallbacks(
fragmentsWithOngoingTransactions.containsKey(fragment)

private fun startTracing(fragment: Fragment) {
if (!fragment.isAdded) {
return
}

val fragmentName = getFragmentName(fragment)

if (scopes.options.isEnableScreenTracking) {
Comment thread
romtsn marked this conversation as resolved.
scopes.configureScope { it.screen = fragmentName }
}

if (!isPerformanceEnabled || isRunningSpan(fragment)) {
return
}

var transaction: ISpan? = null
scopes.configureScope { transaction = it.transaction }

val fragmentName = getFragmentName(fragment)
val span = transaction?.startChild(FRAGMENT_LOAD_OP, fragmentName)

span?.let {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,15 @@ class SentryFragmentLifecycleCallbacksTest {
enableAutoFragmentLifecycleTracing: Boolean = false,
tracesSampleRate: Double? = 1.0,
isAdded: Boolean = true,
enableScreenTracking: Boolean = false,
): SentryFragmentLifecycleCallbacks {
whenever(scopes.options)
.thenReturn(SentryOptions().apply { setTracesSampleRate(tracesSampleRate) })
.thenReturn(
SentryOptions().apply {
setTracesSampleRate(tracesSampleRate)
isEnableScreenTracking = enableScreenTracking
}
)
whenever(span.spanContext)
.thenReturn(SpanContext(SentryId.EMPTY_ID, SpanId.EMPTY_ID, "op", null, null))
whenever(transaction.startChild(any<String>(), any<String>())).thenReturn(span)
Expand Down Expand Up @@ -251,6 +257,115 @@ class SentryFragmentLifecycleCallbacksTest {
verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) })
}

@Test
fun `When fragment view is created via detach-attach, it should start tracing if enabled`() {
// Simulates detach/attach navigation: onFragmentCreated is NOT called, only
// onFragmentViewCreated
val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true)

sut.onFragmentViewCreated(
fixture.fragmentManager,
fixture.fragment,
view = mock(),
savedInstanceState = null,
)

verify(fixture.transaction)
.startChild(
check<String> { assertEquals(SentryFragmentLifecycleCallbacks.FRAGMENT_LOAD_OP, it) },
check<String> { assertEquals("androidx.fragment.app.Fragment", it) },
)
}

@Test
fun `When fragment view is created via detach-attach, it should update screen name`() {
val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true, enableScreenTracking = true)

sut.onFragmentViewCreated(
fixture.fragmentManager,
fixture.fragment,
view = mock(),
savedInstanceState = null,
)

verify(fixture.scope).screen = "androidx.fragment.app.Fragment"
}

@Test
fun `When performance is disabled, it should still update screen name`() {
Comment thread
romtsn marked this conversation as resolved.
val sut =
fixture.getSut(enableAutoFragmentLifecycleTracing = false, enableScreenTracking = true)

sut.onFragmentViewCreated(
fixture.fragmentManager,
fixture.fragment,
view = mock(),
savedInstanceState = null,
)

verify(fixture.scope).screen = "androidx.fragment.app.Fragment"
verify(fixture.transaction, never()).startChild(any<String>(), any<String>())
}

@Test
fun `When fragment view is created after onFragmentCreated, it should not start a second span`() {
// Normal path: onFragmentCreated already started the span; onFragmentViewCreated is a no-op
val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true)

sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null)
sut.onFragmentViewCreated(
fixture.fragmentManager,
fixture.fragment,
view = mock(),
savedInstanceState = null,
)

verify(fixture.transaction).startChild(any<String>(), any<String>())
}

@Test
fun `When fragment is resumed, it should stop tracing if span is still running`() {
// Simulates detach/attach path where onFragmentStarted may be skipped
val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true)

sut.onFragmentViewCreated(
fixture.fragmentManager,
fixture.fragment,
view = mock(),
savedInstanceState = null,
)
sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment)

verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) })
}

@Test
fun `When fragment is resumed after started, it should not double-finish the span`() {
// Normal path: onFragmentStarted already stopped the span; onFragmentResumed is a no-op
val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true)

sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null)
sut.onFragmentStarted(fixture.fragmentManager, fixture.fragment)
sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment)

verify(fixture.span).finish(any())
}

@Test
fun `When fragment view is destroyed before started, it should stop tracing as failsafe`() {
val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true)

sut.onFragmentViewCreated(
fixture.fragmentManager,
fixture.fragment,
view = mock(),
savedInstanceState = null,
)
sut.onFragmentViewDestroyed(fixture.fragmentManager, fixture.fragment)

verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) })
}

private fun verifyBreadcrumbAdded(expectedState: String) {
verify(fixture.scopes)
.addBreadcrumb(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
android:name=".ThirdActivityFragment"
android:exported="false" />

<activity
android:name=".DetachAttachTabsActivity"
android:exported="false"
android:theme="@style/AppTheme.Main" />

<activity
android:name=".GesturesActivity"
android:exported="false" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package io.sentry.samples.android

import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit

class DetachAttachTabsActivity : AppCompatActivity(R.layout.activity_detach_attach_tabs) {
Comment thread
romtsn marked this conversation as resolved.

private val tags = arrayOf("tab_a", "tab_b")

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

findViewById<View>(R.id.btn_tab_a).setOnClickListener { showTab(0) }
findViewById<View>(R.id.btn_tab_b).setOnClickListener { showTab(1) }

if (savedInstanceState == null) {
val tabB = TabFragmentB()
supportFragmentManager.commit {
add(R.id.tab_container, TabFragmentA(), tags[0])
add(R.id.tab_container, tabB, tags[1])
detach(tabB)
}
}
}

private fun showTab(index: Int) {
supportFragmentManager.commit {
for (i in tags.indices) {
val frag = supportFragmentManager.findFragmentByTag(tags[i]) ?: continue
if (i == index) attach(frag) else detach(frag)
}
}
}
}

class TabFragmentA : Fragment(R.layout.fragment_tab) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<TextView>(R.id.tab_label).text = "Tab A"
}
}

class TabFragmentB : Fragment(R.layout.fragment_tab) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<TextView>(R.id.tab_label).text = "Tab B"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,18 @@ fun IntegrationsScreen() {
}
}
}
item {
SentryTraced("open_detach_attach_tabs") {
OutlinedButton(
onClick = {
activity.startActivity(Intent(activity, DetachAttachTabsActivity::class.java))
},
modifier = Modifier,
) {
Text("Open Detach/Attach Tabs", maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
item {
SentryTraced("open_permissions_activity") {
OutlinedButton(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">

<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">

<Button
android:id="@+id/btn_tab_a"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Tab A" />

<Button
android:id="@+id/btn_tab_b"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Tab B" />
</LinearLayout>

<FrameLayout
android:id="@+id/tab_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
android:id="@+id/tab_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="24sp" />
</FrameLayout>
Loading