Skip to content
Merged
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 @@ -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

Expand Down
48 changes: 44 additions & 4 deletions packages/core/src/js/tracing/onSpanEndUtils.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -174,20 +180,54 @@ 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<typeof setTimeout> | 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;
}
}
});

subscription &&
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?.();
}
});
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/js/tracing/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}

Expand Down
99 changes: 98 additions & 1 deletion packages/core/test/tracing/idleNavigationSpan.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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({
Expand Down
Loading