From 55787df879fb9b4c98edc3430de1ff9e74a9a2ac Mon Sep 17 00:00:00 2001 From: roman Date: Mon, 29 Jun 2026 20:09:11 +0000 Subject: [PATCH 1/7] fix(android-fragment): support detach/attach navigation in fragment tracing For detach/attach tab navigation (manual tab switching, ViewPager v1 with FragmentPagerAdapter, custom navigation frameworks), onFragmentCreated is skipped for off-screen fragments that are re-attached. Previously this left ui.load spans open until the 30s activity transaction deadline, producing inflated performance data. Fix by calling startTracing in onFragmentViewCreated as well as onFragmentCreated. startTracing is idempotent (no-op if a span is already running), so the normal onFragmentCreated -> onFragmentViewCreated path is unaffected. Add matching stopTracing calls in onFragmentResumed (covers the detach/attach path where onFragmentStarted may be skipped) and onFragmentViewDestroyed (failsafe for fragments destroyed before reaching STARTED or RESUMED). stopTracing is also idempotent, so the normal path is unaffected. Co-Authored-By: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- .../SentryFragmentLifecycleCallbacks.kt | 21 +++- .../SentryFragmentLifecycleCallbacksTest.kt | 116 ++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt index 230510fb4de..eba4ffb1d39 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt @@ -93,17 +93,32 @@ 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. + if (fragment.isAdded) { + 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) { @@ -116,6 +131,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) { diff --git a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt index 9446e1caef5..421463f647f 100644 --- a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt +++ b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt @@ -251,6 +251,64 @@ 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 { assertEquals(SentryFragmentLifecycleCallbacks.FRAGMENT_LOAD_OP, it) }, + check { assertEquals("androidx.fragment.app.Fragment", it) }, + ) + } + + @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(), any()) + } + + @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( @@ -265,6 +323,64 @@ class SentryFragmentLifecycleCallbacksTest { ) } + @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 { assertEquals(SentryFragmentLifecycleCallbacks.FRAGMENT_LOAD_OP, it) }, + check { assertEquals("androidx.fragment.app.Fragment", it) }, + ) + } + + @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(), any()) + } + + @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 verifyBreadcrumbAddedCount(count: Int) { verify(fixture.scopes, times(count)).addBreadcrumb(any(), anyOrNull()) } From d86192548940721521a82cf21a56fcf5dfa2c564 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 29 Jun 2026 20:14:58 +0000 Subject: [PATCH 2/7] Format code --- .../SentryFragmentLifecycleCallbacksTest.kt | 62 ++++++++++++++++--- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt index 421463f647f..2fb17d8704d 100644 --- a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt +++ b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt @@ -253,10 +253,16 @@ class SentryFragmentLifecycleCallbacksTest { @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 + // 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) + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) verify(fixture.transaction) .startChild( @@ -271,7 +277,12 @@ class SentryFragmentLifecycleCallbacksTest { val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) - sut.onFragmentViewCreated(fixture.fragmentManager, fixture.fragment, view = mock(), savedInstanceState = null) + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) verify(fixture.transaction).startChild(any(), any()) } @@ -281,7 +292,12 @@ class SentryFragmentLifecycleCallbacksTest { // 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.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment) verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) @@ -303,7 +319,12 @@ class SentryFragmentLifecycleCallbacksTest { 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.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) sut.onFragmentViewDestroyed(fixture.fragmentManager, fixture.fragment) verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) @@ -325,10 +346,16 @@ class SentryFragmentLifecycleCallbacksTest { @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 + // 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) + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) verify(fixture.transaction) .startChild( @@ -343,7 +370,12 @@ class SentryFragmentLifecycleCallbacksTest { val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) - sut.onFragmentViewCreated(fixture.fragmentManager, fixture.fragment, view = mock(), savedInstanceState = null) + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) verify(fixture.transaction).startChild(any(), any()) } @@ -353,7 +385,12 @@ class SentryFragmentLifecycleCallbacksTest { // 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.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment) verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) @@ -375,7 +412,12 @@ class SentryFragmentLifecycleCallbacksTest { 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.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) sut.onFragmentViewDestroyed(fixture.fragmentManager, fixture.fragment) verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) From a7838453ae9239296d3f114aa20b74c9eeea56be Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 30 Jun 2026 15:52:44 +0200 Subject: [PATCH 3/7] Add changelog entry and detach/attach sample Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../src/main/AndroidManifest.xml | 4 ++ .../android/DetachAttachTabsActivity.kt | 50 +++++++++++++++++++ .../io/sentry/samples/android/MainActivity.kt | 12 +++++ .../layout/activity_detach_attach_tabs.xml | 31 ++++++++++++ .../src/main/res/layout/fragment_tab.xml | 12 +++++ 6 files changed, 110 insertions(+) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml create mode 100644 sentry-samples/sentry-samples-android/src/main/res/layout/fragment_tab.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a37500b9a1..76aa5fd0881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Fix fragment tracing not working with detach/attach navigation ([#5660](https://github.com/getsentry/sentry-java/pull/5660)) - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) ### Performance diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 1150dd5ef2e..a7f907e5b9f 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -65,6 +65,10 @@ android:name=".ThirdActivityFragment" android:exported="false" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt new file mode 100644 index 00000000000..3a38814c5d8 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt @@ -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) { + + private val tags = arrayOf("tab_a", "tab_b") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + findViewById(R.id.btn_tab_a).setOnClickListener { showTab(0) } + findViewById(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(R.id.tab_label).text = "Tab A" + } +} + +class TabFragmentB : Fragment(R.layout.fragment_tab) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.tab_label).text = "Tab B" + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index b87e7a3190c..d53f7e4687f 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -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( diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml new file mode 100644 index 00000000000..0b414975e92 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml @@ -0,0 +1,31 @@ + + + + + +