Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
- Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657))

### Performance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,85 @@ 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 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 All @@ -265,6 +344,85 @@ 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<String> { assertEquals(SentryFragmentLifecycleCallbacks.FRAGMENT_LOAD_OP, it) },
check<String> { 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<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 verifyBreadcrumbAddedCount(count: Int) {
verify(fixture.scopes, times(count)).addBreadcrumb(any<Breadcrumb>(), anyOrNull())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@
android:name=".ThirdActivityFragment"
android:exported="false" />

<activity
android:name=".DetachAttachTabsActivity"
android:exported="false" />

<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) {

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,31 @@
<?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">

<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>