diff --git a/CHANGELOG.md b/CHANGELOG.md index eb2e0c92a1..d6eea879d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixes - Resolve relative `SOURCEMAP_FILE` paths against the project root in the Xcode build script ([#5730](https://github.com/getsentry/sentry-react-native/pull/5730)) +- Handle `inactive` state for spans ([#5742](https://github.com/getsentry/sentry-react-native/pull/5742)) ### Dependencies diff --git a/packages/core/src/js/tracing/onSpanEndUtils.ts b/packages/core/src/js/tracing/onSpanEndUtils.ts index 07c68ae3fc..758560ad06 100644 --- a/packages/core/src/js/tracing/onSpanEndUtils.ts +++ b/packages/core/src/js/tracing/onSpanEndUtils.ts @@ -1,9 +1,15 @@ import type { Client, Span } from '@sentry/core'; import { debug, getSpanDescendants, SPAN_STATUS_ERROR, spanToJSON } from '@sentry/core'; import type { AppStateStatus } from 'react-native'; -import { AppState } from 'react-native'; +import { AppState, Platform } from 'react-native'; import { isRootSpan, isSentrySpan } from '../utils/span'; +/** + * The time to wait after the app enters the `inactive` state on iOS before + * cancelling the span. + */ +const IOS_INACTIVE_CANCEL_DELAY_MS = 5_000; + /** * Hooks on span end event to execute a callback when the span ends. */ @@ -174,13 +180,43 @@ export const onlySampleIfChildSpans = (client: Client, span: Span): void => { /** * Hooks on AppState change to cancel the span if the app goes background. + * + * On iOS the JS thread can be suspended between the `inactive` and + * `background` transitions, which means the `background` event may never + * reach JS in time. To handle this we schedule a deferred cancellation when + * the app becomes `inactive`. If the app returns to `active` before the + * timeout fires, the cancellation is cleared. If it transitions to + * `background` first, we cancel immediately and clear the timeout. */ export const cancelInBackground = (client: Client, span: Span): void => { + let inactiveTimeout: ReturnType | undefined; + + const cancelSpan = (): void => { + if (inactiveTimeout !== undefined) { + clearTimeout(inactiveTimeout); + inactiveTimeout = undefined; + } + debug.log(`Setting ${spanToJSON(span).op} transaction to cancelled because the app is in the background.`); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); + span.end(); + }; + const subscription = AppState.addEventListener('change', (newState: AppStateStatus) => { if (newState === 'background') { - debug.log(`Setting ${spanToJSON(span).op} transaction to cancelled because the app is in the background.`); - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); - span.end(); + cancelSpan(); + } else if (Platform.OS === 'ios' && newState === 'inactive') { + // Schedule a deferred cancellation — if the JS thread is suspended + // before the 'background' event fires, this timer will execute when + // the app is eventually resumed and end the span. + if (inactiveTimeout === undefined) { + inactiveTimeout = setTimeout(cancelSpan, IOS_INACTIVE_CANCEL_DELAY_MS); + } + } else if (newState === 'active') { + // App returned to foreground — clear any pending inactive cancellation. + if (inactiveTimeout !== undefined) { + clearTimeout(inactiveTimeout); + inactiveTimeout = undefined; + } } }); @@ -188,6 +224,10 @@ export const cancelInBackground = (client: Client, span: Span): void => { client.on('spanEnd', (endedSpan: Span) => { if (endedSpan === span) { debug.log(`Removing AppState listener for ${spanToJSON(span).op} transaction.`); + if (inactiveTimeout !== undefined) { + clearTimeout(inactiveTimeout); + inactiveTimeout = undefined; + } subscription?.remove?.(); } }); diff --git a/packages/core/src/js/tracing/span.ts b/packages/core/src/js/tracing/span.ts index 030c12028a..90f2b401b8 100644 --- a/packages/core/src/js/tracing/span.ts +++ b/packages/core/src/js/tracing/span.ts @@ -12,7 +12,7 @@ import { spanToJSON, startIdleSpan as coreStartIdleSpan, } from '@sentry/core'; -import { AppState } from 'react-native'; +import { AppState, Platform } from 'react-native'; import { isRootSpan } from '../utils/span'; import { adjustTransactionDuration, cancelInBackground } from './onSpanEndUtils'; import { @@ -118,8 +118,10 @@ export const startIdleSpan = ( } const currentAppState = AppState.currentState; - if (currentAppState === 'background') { - debug.log(`[startIdleSpan] App is already in background, not starting span for ${startSpanOption.name}`); + if (currentAppState === 'background' || (Platform.OS === 'ios' && currentAppState === 'inactive')) { + debug.log( + `[startIdleSpan] App is already in '${currentAppState}' state, not starting span for ${startSpanOption.name}`, + ); return new SentryNonRecordingSpan(); } diff --git a/packages/core/test/tracing/idleNavigationSpan.test.ts b/packages/core/test/tracing/idleNavigationSpan.test.ts index d0c598ca38..a0838d64cc 100644 --- a/packages/core/test/tracing/idleNavigationSpan.test.ts +++ b/packages/core/test/tracing/idleNavigationSpan.test.ts @@ -1,5 +1,5 @@ import type { Span } from '@sentry/core'; -import { getActiveSpan, getCurrentScope, spanToJSON, startSpanManual } from '@sentry/core'; +import { getActiveSpan, getCurrentScope, spanToJSON, startInactiveSpan, startSpanManual } from '@sentry/core'; import type { AppStateStatus } from 'react-native'; import { AppState } from 'react-native'; import type { ScopeWithMaybeSpan } from '../../src/js/tracing/span'; @@ -117,6 +117,103 @@ describe('startIdleNavigationSpan', () => { expect(mockedAppState.removeSubscription).not.toHaveBeenCalled(); }); + it('Returns non-recording span when app is already inactive', () => { + mockedAppState.currentState = 'inactive'; + + const span = startIdleNavigationSpan({ + name: 'test', + }); + + expect(getActiveSpan()).toBeUndefined(); + expect(span).toBeDefined(); + expect(span?.constructor.name).toBe('SentryNonRecordingSpan'); + expect(mockedAppState.removeSubscription).not.toHaveBeenCalled(); + }); + + describe('cancelInBackground with iOS inactive state', () => { + it('Schedules deferred cancellation on inactive and cancels after timeout', () => { + const routeTransaction = startIdleNavigationSpan({ + name: 'test', + }); + + // Keep the span alive with an open child span (simulates in-flight network request) + startInactiveSpan({ name: 'child-span' }); + + // App goes inactive (e.g. user presses home button on iOS) + mockedAppState.setState('inactive'); + + // Span should still be open — not cancelled immediately + expect(spanToJSON(routeTransaction!).status).not.toBe('cancelled'); + expect(spanToJSON(routeTransaction!).timestamp).toBeUndefined(); + + // Advance past the deferred cancellation timeout (5 seconds) + jest.advanceTimersByTime(5_000); + + // Now the deferred cancellation should have fired + expect(spanToJSON(routeTransaction!).status).toBe('cancelled'); + expect(spanToJSON(routeTransaction!).timestamp).toBeDefined(); + expect(mockedAppState.removeSubscription).toHaveBeenCalledTimes(1); + }); + + it('Clears deferred cancellation when app returns to active', () => { + const routeTransaction = startIdleNavigationSpan({ + name: 'test', + }); + + // App goes inactive (e.g. Control Center pulled down) + mockedAppState.setState('inactive'); + + // Advance part way — not enough for the timeout to fire + jest.advanceTimersByTime(2_000); + + // App returns to active (Control Center dismissed) + mockedAppState.setState('active'); + + // Advance well past the original timeout + jest.advanceTimersByTime(10_000); + + // Span should NOT be cancelled — it's still recording + expect(spanToJSON(routeTransaction!).status).not.toBe('cancelled'); + }); + + it('Cancels immediately on background even if inactive timeout is pending', () => { + const routeTransaction = startIdleNavigationSpan({ + name: 'test', + }); + + // App goes inactive first + mockedAppState.setState('inactive'); + + // Then transitions to background before the timeout fires + jest.advanceTimersByTime(100); + mockedAppState.setState('background'); + + // Span should be cancelled immediately + expect(spanToJSON(routeTransaction!).status).toBe('cancelled'); + expect(spanToJSON(routeTransaction!).timestamp).toBeDefined(); + expect(mockedAppState.removeSubscription).toHaveBeenCalledTimes(1); + }); + + it('Clears inactive timeout when span ends normally', () => { + const routeTransaction = startIdleNavigationSpan({ + name: 'test', + }); + + // App goes inactive — deferred cancellation is scheduled + mockedAppState.setState('inactive'); + + // Span ends normally (e.g. idle timeout, new navigation) + routeTransaction!.end(); + + jest.runAllTimers(); + + // AppState listener should be cleaned up + expect(mockedAppState.removeSubscription).toHaveBeenCalledTimes(1); + // Span should not have the cancelled status since it ended normally + expect(spanToJSON(routeTransaction!).status).not.toBe('cancelled'); + }); + }); + describe('Start a new active root span (without parent)', () => { it('Starts a new span when there is no active span', () => { const span = startIdleNavigationSpan({