Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -10,6 +10,7 @@

### Features

- Add breadcrumbs for dispatched React Navigation events ([#6218](https://github.com/getsentry/sentry-react-native/pull/6218))
- Add `disableAutoUpload` option to Expo plugin to disable source map and debug symbol uploads ([#6195](https://github.com/getsentry/sentry-react-native/pull/6195))
- Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192))
- Better route and dynamic param extraction for Expo Router ([#6197](https://github.com/getsentry/sentry-react-native/pull/6197))
Expand Down
22 changes: 20 additions & 2 deletions packages/core/src/js/tracing/reactnavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,12 +347,30 @@ export const reactNavigationIntegration = ({
// oxlint-disable-next-line eslint(complexity)
const startIdleNavigationSpan = (unknownEvent?: unknown, isAppRestart = false): void => {
const event = unknownEvent as UnsafeAction | undefined;
const actionType = event?.data?.action?.type;
const targetRouteName = getRouteNameFromAction(event);

if (event && !isAppRestart && !event.data?.noop) {
addBreadcrumb({
category: 'navigation.dispatch',
type: 'navigation',
message: targetRouteName
? `Dispatched ${actionType ?? 'NAVIGATE'} to ${targetRouteName}`
: `Dispatched ${actionType ?? 'NAVIGATE'}`,
data: {
...(actionType ? { action_type: actionType } : undefined),
...(targetRouteName ? { to: targetRouteName } : undefined),
},
level: 'info',
});
}
Comment thread
antonis marked this conversation as resolved.

if (useDispatchedActionData && event?.data.noop) {
debug.log(`${INTEGRATION_NAME} Navigation action is a noop, not starting navigation span.`);
return;
}

const navigationActionType = useDispatchedActionData ? event?.data.action.type : undefined;
const navigationActionType = useDispatchedActionData ? actionType : undefined;

// Handle PRELOAD actions separately if prefetch tracking is enabled
if (enablePrefetchTracking && navigationActionType === 'PRELOAD') {
Expand Down Expand Up @@ -407,7 +425,7 @@ export const reactNavigationIntegration = ({
}

// Extract route name from dispatch action payload when available
const dispatchedRouteName = useDispatchedActionData ? getRouteNameFromAction(event) : undefined;
const dispatchedRouteName = useDispatchedActionData ? targetRouteName : undefined;
if (useDispatchedActionData && event && !dispatchedRouteName && !isAppRestart) {
debug.log(`${INTEGRATION_NAME} Navigation action has no route name in payload, not starting navigation span.`);
return;
Expand Down
182 changes: 182 additions & 0 deletions packages/core/test/tracing/reactnavigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Event, Measurements, SentrySpan, StartSpanOptions } from '@sentry/core';

import * as core from '@sentry/core';
import {
getActiveSpan,
getCurrentScope,
Expand Down Expand Up @@ -1641,6 +1642,187 @@ describe('ReactNavigationInstrumentation', () => {
});
});

describe('dispatch breadcrumbs', () => {
let addBreadcrumbSpy: jest.SpyInstance;

beforeEach(() => {
addBreadcrumbSpy = jest.spyOn(core, 'addBreadcrumb');
});

afterEach(() => {
addBreadcrumbSpy.mockRestore();
});

it('includes action type and route name even when useDispatchedActionData is disabled', async () => {
setupTestClient();
mockNavigation.navigateToNewScreenWithPayload();
await jest.advanceTimersByTimeAsync(500);

expect(addBreadcrumbSpy).toHaveBeenCalledWith(
expect.objectContaining({
category: 'navigation.dispatch',
type: 'navigation',
message: 'Dispatched NAVIGATE to New Screen',
data: expect.objectContaining({
action_type: 'NAVIGATE',
to: 'New Screen',
}),
}),
);
});

it('includes action type and route name when useDispatchedActionData is enabled', async () => {
setupTestClient({ useDispatchedActionData: true });
mockNavigation.navigateToNewScreenWithPayload();
await jest.advanceTimersByTimeAsync(500);

expect(addBreadcrumbSpy).toHaveBeenCalledWith(
expect.objectContaining({
category: 'navigation.dispatch',
type: 'navigation',
message: 'Dispatched NAVIGATE to New Screen',
data: expect.objectContaining({
action_type: 'NAVIGATE',
to: 'New Screen',
}),
}),
);
});

it('creates dispatch breadcrumb without route name for GO_BACK', async () => {
setupTestClient({ useDispatchedActionData: true });
mockNavigation.emitGoBackWithStateChange();
await jest.advanceTimersByTimeAsync(500);

expect(addBreadcrumbSpy).toHaveBeenCalledWith(
expect.objectContaining({
category: 'navigation.dispatch',
type: 'navigation',
message: 'Dispatched GO_BACK',
data: expect.objectContaining({
action_type: 'GO_BACK',
}),
}),
);
});

it('creates dispatch breadcrumb for filtered actions like SET_PARAMS', async () => {
setupTestClient({ useDispatchedActionData: true });
mockNavigation.emitWithoutStateChange({
data: {
action: { type: 'SET_PARAMS' },
noop: false,
stack: undefined,
},
});
await jest.advanceTimersByTimeAsync(500);

expect(addBreadcrumbSpy).toHaveBeenCalledWith(
expect.objectContaining({
category: 'navigation.dispatch',
message: 'Dispatched SET_PARAMS',
data: expect.objectContaining({
action_type: 'SET_PARAMS',
}),
}),
);
});

it('creates dispatch breadcrumb for drawer actions', async () => {
setupTestClient({ useDispatchedActionData: true });
mockNavigation.emitWithoutStateChange({
data: {
action: { type: 'OPEN_DRAWER' },
noop: false,
stack: undefined,
},
});
await jest.advanceTimersByTimeAsync(500);

expect(addBreadcrumbSpy).toHaveBeenCalledWith(
expect.objectContaining({
category: 'navigation.dispatch',
message: 'Dispatched OPEN_DRAWER',
data: expect.objectContaining({
action_type: 'OPEN_DRAWER',
}),
}),
);
});

it('does not create dispatch breadcrumb for noop actions when useDispatchedActionData is enabled', async () => {
setupTestClient({ useDispatchedActionData: true });
mockNavigation.emitWithoutStateChange({
data: {
action: { type: 'NAVIGATE' },
noop: true,
stack: undefined,
},
});
await jest.advanceTimersByTimeAsync(500);

const dispatchCall = addBreadcrumbSpy.mock.calls.find(
(call: unknown[]) => (call[0] as { category?: string }).category === 'navigation.dispatch',
);
expect(dispatchCall).toBeUndefined();
});

it('does not create dispatch breadcrumb for noop actions when useDispatchedActionData is disabled', async () => {
setupTestClient();
mockNavigation.emitWithoutStateChange({
data: {
action: { type: 'NAVIGATE' },
noop: true,
stack: undefined,
},
});
await jest.advanceTimersByTimeAsync(500);

const dispatchCall = addBreadcrumbSpy.mock.calls.find(
(call: unknown[]) => (call[0] as { category?: string }).category === 'navigation.dispatch',
);
expect(dispatchCall).toBeUndefined();
});

it('still creates navigation breadcrumb on completed navigation', async () => {
setupTestClient();
mockNavigation.navigateToNewScreen();
await jest.advanceTimersByTimeAsync(500);

expect(addBreadcrumbSpy).toHaveBeenCalledWith(
expect.objectContaining({
category: 'navigation',
type: 'navigation',
message: 'Navigation to New Screen',
}),
);
});

it('does not create dispatch breadcrumb for app restart', async () => {
const rNavigation = reactNavigationIntegration({ routeChangeTimeoutMs: 200 });
const rnTracing = reactNativeTracingIntegration();
const options = getDefaultTestClientOptions({
enableNativeFramesTracking: false,
enableStallTracking: false,
tracesSampleRate: 1.0,
integrations: [rNavigation, rnTracing],
enableAppStartTracking: false,
});
client = new TestClient(options);
setCurrentClient(client);
client.init();
mockNavigation = createMockNavigationAndAttachTo(rNavigation);

mockNavigation.finishAppStartNavigation();
await jest.advanceTimersByTimeAsync(500);

const dispatchCall = addBreadcrumbSpy.mock.calls.find(
(call: unknown[]) => (call[0] as { category?: string }).category === 'navigation.dispatch',
);
expect(dispatchCall).toBeUndefined();
});
});

function setupTestClient(
setupOptions: {
beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions;
Expand Down
Loading