Skip to content

Commit a789a67

Browse files
feat(tracing): Use React Navigation dispatched action data to discard noops (#4684)
1 parent dd1a89d commit a789a67

9 files changed

Lines changed: 235 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
### Features
1212

1313
- Improve Warm App Start reporting on Android ([#4641](https://github.com/getsentry/sentry-react-native/pull/4641))
14+
- Add `createTimeToInitialDisplay({useFocusEffect})` and `createTimeToFullDisplay({useFocusEffect})` to allow record full display on screen focus ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665))
1415
- Add support for measuring Time to Initial Display for already seen routes ([#4661](https://github.com/getsentry/sentry-react-native/pull/4661))
1516
- Introduce `enableTimeToInitialDisplayForPreloadedRoutes` option to the React Navigation integration.
1617

@@ -20,7 +21,14 @@
2021
});
2122
```
2223

23-
- Add `createTimeToInitialDisplay({useFocusEffect})` and `createTimeToFullDisplay({useFocusEffect})` to allow record full display on screen focus ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665))
24+
- Add `useDispatchedActionData` option to the React Navigation integration to filter out navigation actions that should not create spans ([#4684](https://github.com/getsentry/sentry-react-native/pull/4684))
25+
- For example `PRELOAD`, `SET_PARAMS`, `TOGGLE_DRAWER` and others.
26+
27+
```js
28+
Sentry.reactNavigationIntegration({
29+
useDispatchedActionData: true,
30+
});
31+
```
2432

2533
### Fixes
2634

packages/core/src/js/tracing/reactnavigation.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ import {
1616
import { getAppRegistryIntegration } from '../integrations/appRegistry';
1717
import { isSentrySpan } from '../utils/span';
1818
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
19+
import type { UnsafeAction } from '../vendor/react-navigation/types';
1920
import { NATIVE } from '../wrapper';
2021
import { ignoreEmptyBackNavigation } from './onSpanEndUtils';
2122
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin';
2223
import type { ReactNativeTracingIntegration } from './reactnativetracing';
2324
import { getReactNativeTracingIntegration } from './reactnativetracing';
24-
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes';
25+
import { SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes';
2526
import {
2627
DEFAULT_NAVIGATION_SPAN_NAME,
2728
defaultIdleOptions,
@@ -65,6 +66,13 @@ interface ReactNavigationIntegrationOptions {
6566
* @default false
6667
*/
6768
enableTimeToInitialDisplayForPreloadedRoutes: boolean;
69+
70+
/**
71+
* Whether to use the dispatched action data to populate the transaction metadata.
72+
*
73+
* @default false
74+
*/
75+
useDispatchedActionData: boolean;
6876
}
6977

7078
/**
@@ -80,6 +88,7 @@ export const reactNavigationIntegration = ({
8088
enableTimeToInitialDisplay = false,
8189
ignoreEmptyBackNavigationTransactions = true,
8290
enableTimeToInitialDisplayForPreloadedRoutes = false,
91+
useDispatchedActionData = false,
8392
}: Partial<ReactNavigationIntegrationOptions> = {}): Integration & {
8493
/**
8594
* Pass the ref to the navigation container to register it to the instrumentation
@@ -201,7 +210,30 @@ export const reactNavigationIntegration = ({
201210
* It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change
202211
* and gets the route information from there, @see updateLatestNavigationSpanWithCurrentRoute
203212
*/
204-
const startIdleNavigationSpan = (): void => {
213+
const startIdleNavigationSpan = (unknownEvent?: unknown): void => {
214+
const event = unknownEvent as UnsafeAction | undefined;
215+
if (useDispatchedActionData && event?.data.noop) {
216+
logger.debug(`${INTEGRATION_NAME} Navigation action is a noop, not starting navigation span.`);
217+
return;
218+
}
219+
220+
const navigationActionType = useDispatchedActionData ? event?.data.action.type : undefined;
221+
if (
222+
useDispatchedActionData &&
223+
[
224+
// Process common actions
225+
'PRELOAD',
226+
'SET_PARAMS',
227+
// Drawer actions
228+
'OPEN_DRAWER',
229+
'CLOSE_DRAWER',
230+
'TOGGLE_DRAWER',
231+
].includes(navigationActionType)
232+
) {
233+
logger.debug(`${INTEGRATION_NAME} Navigation action is ${navigationActionType}, not starting navigation span.`);
234+
return;
235+
}
236+
205237
if (latestNavigationSpan) {
206238
logger.log(`${INTEGRATION_NAME} A transaction was detected that turned out to be a noop, discarding.`);
207239
_discardLatestTransaction();
@@ -215,6 +247,7 @@ export const reactNavigationIntegration = ({
215247
idleSpanOptions,
216248
);
217249
latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION);
250+
latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, navigationActionType);
218251
if (ignoreEmptyBackNavigationTransactions) {
219252
ignoreEmptyBackNavigation(getClient(), latestNavigationSpan);
220253
}
@@ -357,6 +390,7 @@ export const reactNavigationIntegration = ({
357390
enableTimeToInitialDisplay,
358391
ignoreEmptyBackNavigationTransactions,
359392
enableTimeToInitialDisplayForPreloadedRoutes,
393+
useDispatchedActionData,
360394
},
361395
};
362396
};
@@ -369,7 +403,7 @@ export interface NavigationRoute {
369403
}
370404

371405
interface NavigationContainer {
372-
addListener: (type: string, listener: () => void) => void;
406+
addListener: (type: string, listener: (event?: unknown) => void) => void;
373407
getCurrentRoute: () => NavigationRoute;
374408
}
375409

packages/core/src/js/tracing/semanticAttributes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_KEY = 'previous_route.key';
1717
export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_ID = 'previous_route.component_id';
1818
export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_TYPE = 'previous_route.component_type';
1919
export const SEMANTIC_ATTRIBUTE_TIME_TO_INITIAL_DISPLAY_FALLBACK = 'route.initial_display_fallback';
20+
export const SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE = 'navigation.action_type';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// MIT License
2+
3+
// Copyright (c) 2017 React Navigation Contributors
4+
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
// https://github.com/react-navigation/react-navigation/blob/a656331009fcb534e0bef535e6df65ae07a228a4/packages/core/src/types.tsx#L777
24+
25+
/**
26+
* Event which fires when an action is dispatched.
27+
* Only intended for debugging purposes, don't use it for app logic.
28+
* This event will be emitted before state changes have been applied.
29+
*/
30+
export type UnsafeAction = {
31+
data: {
32+
/**
33+
* The action object which was dispatched.
34+
*/
35+
action: {
36+
readonly type: string;
37+
readonly payload?: object | undefined;
38+
readonly source?: string | undefined;
39+
readonly target?: string | undefined;
40+
};
41+
/**
42+
* Whether the action was a no-op, i.e. resulted any state changes.
43+
*/
44+
noop: boolean;
45+
/**
46+
* Stack trace of the action, this will only be available during development.
47+
*/
48+
stack: string | undefined;
49+
};
50+
};

packages/core/test/tracing/reactnavigation.test.ts

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,8 +362,8 @@ describe('ReactNavigationInstrumentation', () => {
362362
current: mockNavigationContainer,
363363
});
364364

365-
// Reset mocks after the first registration
366-
jest.resetAllMocks();
365+
// Clear mocks after the first registration
366+
jest.clearAllMocks();
367367

368368
instrumentation.registerNavigationContainer({
369369
current: mockNavigationContainer,
@@ -579,6 +579,94 @@ describe('ReactNavigationInstrumentation', () => {
579579
});
580580
});
581581

582+
[true, false].forEach(useDispatchedActionData => {
583+
describe(`test actions which should not create a navigation span when useDispatchedActionData is ${useDispatchedActionData}`, () => {
584+
beforeEach(async () => {
585+
setupTestClient({ useDispatchedActionData });
586+
await jest.runOnlyPendingTimers(); // Flushes the initial navigation span
587+
client.event = undefined;
588+
});
589+
590+
test(`noop does ${useDispatchedActionData ? 'not' : ''}create a navigation span`, async () => {
591+
mockNavigation.emitWithStateChange({
592+
data: {
593+
action: {
594+
type: 'UNKNOWN',
595+
},
596+
noop: true,
597+
stack: undefined,
598+
},
599+
});
600+
await jest.runOnlyPendingTimersAsync();
601+
await client.flush();
602+
603+
expect(client.event === undefined).toBe(useDispatchedActionData);
604+
});
605+
606+
test.each(['PRELOAD', 'SET_PARAMS', 'OPEN_DRAWER', 'CLOSE_DRAWER', 'TOGGLE_DRAWER'])(
607+
`%s does ${useDispatchedActionData ? 'not' : ''}create a navigation span`,
608+
async actionType => {
609+
mockNavigation.emitWithStateChange({
610+
data: {
611+
action: {
612+
type: actionType,
613+
},
614+
noop: false,
615+
stack: undefined,
616+
},
617+
});
618+
await jest.runOnlyPendingTimersAsync();
619+
await client.flush();
620+
621+
expect(client.event === undefined).toBe(useDispatchedActionData);
622+
},
623+
);
624+
});
625+
});
626+
627+
test('noop does not remove the previous navigation span from scope', async () => {
628+
setupTestClient({ useDispatchedActionData: true });
629+
await jest.runOnlyPendingTimers(); // Flushes the initial navigation span
630+
631+
mockNavigation.emitNavigationWithoutStateChange();
632+
const activeSpan = getActiveSpan();
633+
634+
mockNavigation.emitWithoutStateChange({
635+
data: {
636+
action: {
637+
type: 'UNKNOWN',
638+
},
639+
noop: true,
640+
stack: undefined,
641+
},
642+
});
643+
644+
expect(getActiveSpan()).toBe(activeSpan);
645+
});
646+
647+
test.each(['PRELOAD', 'SET_PARAMS', 'OPEN_DRAWER', 'CLOSE_DRAWER', 'TOGGLE_DRAWER'])(
648+
'%s does not remove the previous navigation span from scope',
649+
async actionType => {
650+
setupTestClient({ useDispatchedActionData: true });
651+
await jest.runOnlyPendingTimers(); // Flushes the initial navigation span
652+
653+
mockNavigation.emitNavigationWithoutStateChange();
654+
const activeSpan = getActiveSpan();
655+
656+
mockNavigation.emitWithoutStateChange({
657+
data: {
658+
action: {
659+
type: actionType,
660+
},
661+
noop: false,
662+
stack: undefined,
663+
},
664+
});
665+
666+
expect(getActiveSpan()).toBe(activeSpan);
667+
},
668+
);
669+
582670
describe('setCurrentRoute', () => {
583671
let mockSetCurrentRoute: jest.Mock;
584672

@@ -649,10 +737,12 @@ describe('ReactNavigationInstrumentation', () => {
649737
function setupTestClient(
650738
setupOptions: {
651739
beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions;
740+
useDispatchedActionData?: boolean;
652741
} = {},
653742
) {
654743
const rNavigation = reactNavigationIntegration({
655744
routeChangeTimeoutMs: 200,
745+
useDispatchedActionData: setupOptions.useDispatchedActionData,
656746
});
657747
mockNavigation = createMockNavigationAndAttachTo(rNavigation);
658748

packages/core/test/tracing/reactnavigationutils.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import type { NavigationRoute, reactNavigationIntegration } from '../../src/js/tracing/reactnavigation';
2+
import type { UnsafeAction } from '../../src/js/vendor/react-navigation/types';
3+
4+
const navigationAction: UnsafeAction = {
5+
data: {
6+
action: {
7+
type: 'NAVIGATE',
8+
},
9+
noop: false,
10+
stack: undefined,
11+
},
12+
};
213

314
export function createMockNavigationAndAttachTo(sut: ReturnType<typeof reactNavigationIntegration>) {
415
const mockedNavigationContained = mockNavigationContainer();
516
const mockedNavigation = {
617
emitCancelledNavigation: () => {
7-
mockedNavigationContained.listeners['__unsafe_action__']({
8-
// this object is not used by the instrumentation
9-
});
18+
mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
1019
},
1120
navigateToNewScreen: () => {
12-
mockedNavigationContained.listeners['__unsafe_action__']({
13-
// this object is not used by the instrumentation
14-
});
21+
mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
1522
mockedNavigationContained.currentRoute = {
1623
key: 'new_screen',
1724
name: 'New Screen',
@@ -21,9 +28,7 @@ export function createMockNavigationAndAttachTo(sut: ReturnType<typeof reactNavi
2128
});
2229
},
2330
navigateToSecondScreen: () => {
24-
mockedNavigationContained.listeners['__unsafe_action__']({
25-
// this object is not used by the instrumentation
26-
});
31+
mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
2732
mockedNavigationContained.currentRoute = {
2833
key: 'second_screen',
2934
name: 'Second Screen',
@@ -33,9 +38,7 @@ export function createMockNavigationAndAttachTo(sut: ReturnType<typeof reactNavi
3338
});
3439
},
3540
navigateToInitialScreen: () => {
36-
mockedNavigationContained.listeners['__unsafe_action__']({
37-
// this object is not used by the instrumentation
38-
});
41+
mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
3942
mockedNavigationContained.currentRoute = {
4043
key: 'initial_screen',
4144
name: 'Initial Screen',
@@ -53,6 +56,18 @@ export function createMockNavigationAndAttachTo(sut: ReturnType<typeof reactNavi
5356
// this object is not used by the instrumentation
5457
});
5558
},
59+
emitNavigationWithoutStateChange: () => {
60+
mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
61+
},
62+
emitWithoutStateChange: (action: UnsafeAction) => {
63+
mockedNavigationContained.listeners['__unsafe_action__'](action);
64+
},
65+
emitWithStateChange: (action: UnsafeAction) => {
66+
mockedNavigationContained.listeners['__unsafe_action__'](action);
67+
mockedNavigationContained.listeners['state']({
68+
// this object is not used by the instrumentation
69+
});
70+
},
5671
};
5772
sut.registerNavigationContainer(mockRef(mockedNavigationContained));
5873

samples/react-native/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const reactNavigationIntegration = Sentry.reactNavigationIntegration({
5454
enableTimeToInitialDisplay: isMobileOs,
5555
ignoreEmptyBackNavigationTransactions: false,
5656
enableTimeToInitialDisplayForPreloadedRoutes: true,
57+
useDispatchedActionData: true,
5758
});
5859

5960
Sentry.init({

samples/react-native/src/Screens/PerformanceScreen.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { CommonActions } from '@react-navigation/native';
1313
import * as Sentry from '@sentry/react-native';
1414
import { Text } from 'react-native';
1515
import { TimeToFullDisplay } from '../utils';
16+
import { preloadArticles } from './SpaceflightNewsScreen';
1617

1718
interface Props {
1819
navigation: StackNavigationProp<any, 'PerformanceScreen'>;
@@ -48,6 +49,14 @@ const PerformanceScreen = (props: Props) => {
4849
props.navigation.navigate('SpaceflightNewsScreen');
4950
}}
5051
/>
52+
<Button
53+
title="Preload Spaceflight News"
54+
onPress={() => {
55+
console.log('Preloading SpaceflightNewsScreen');
56+
props.navigation.preload('SpaceflightNewsScreen');
57+
preloadArticles();
58+
}}
59+
/>
5160
<Button
5261
title="Auto Tracing Example"
5362
onPress={() => {

0 commit comments

Comments
 (0)