diff --git a/CHANGELOG.md b/CHANGELOG.md index 45dde5f777..d125c35170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Add breadcrumbs for dispatched React Navigation events ([#6218](https://github.com/getsentry/sentry-react-native/pull/6218)) - Add `Sentry.NavigationContainer` drop-in wrapper for React Navigation ([#6199](https://github.com/getsentry/sentry-react-native/pull/6199)) - Opt-in: consume sentry-cocoa via Swift Package Manager. Set `SENTRY_USE_SPM=1` before `pod install` to pull `Sentry` from sentry-cocoa's SPM package as a binary xcframework instead of the CocoaPods source build ([#6182](https://github.com/getsentry/sentry-react-native/pull/6182)) - Add `disableAutoUpload` option to Expo plugin to disable source map and debug symbol uploads ([#6195](https://github.com/getsentry/sentry-react-native/pull/6195)) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 0490285510..63eeba6edc 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -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', + }); + } + 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') { @@ -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; diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 0bf59db43b..612720c330 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -1,5 +1,6 @@ import type { Event, Measurements, SentrySpan, StartSpanOptions } from '@sentry/core'; +import * as core from '@sentry/core'; import { getActiveSpan, getCurrentScope, @@ -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;