From c5299adf30c5b801414541a0431f8605631d40b0 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 26 May 2026 11:11:36 +0200 Subject: [PATCH 1/3] feat(tracing): Add breadcrumbs for dispatched React Navigation events Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + .../core/src/js/tracing/reactnavigation.ts | 22 ++- .../core/test/tracing/reactnavigation.test.ts | 165 ++++++++++++++++++ 3 files changed, 186 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b27c9e49..3f645dc023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Add breadcrumbs for dispatched React Navigation events ([#4904](https://github.com/getsentry/sentry-react-native/issues/4904)) - 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)) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 0490285510..c55f1fcc91 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 (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; + if (event && !isAppRestart) { + 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', + }); + } + + 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..7b3ea3af07 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,170 @@ 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', 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('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; From 12a0b968b3df4995f7df14cbb18b8bd2c70c205c Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 26 May 2026 11:15:26 +0200 Subject: [PATCH 2/3] Fix changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f645dc023..1e77626662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- Add breadcrumbs for dispatched React Navigation events ([#4904](https://github.com/getsentry/sentry-react-native/issues/4904)) +- 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)) From 186ae7226845a1cf12fc13144b5669413a718be5 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 26 May 2026 11:21:40 +0200 Subject: [PATCH 3/3] fix(tracing): Filter noop dispatches from breadcrumbs unconditionally Co-Authored-By: Claude Opus 4.6 --- .../core/src/js/tracing/reactnavigation.ts | 12 ++++++------ .../core/test/tracing/reactnavigation.test.ts | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index c55f1fcc91..63eeba6edc 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -350,12 +350,7 @@ export const reactNavigationIntegration = ({ const actionType = event?.data?.action?.type; const targetRouteName = getRouteNameFromAction(event); - if (useDispatchedActionData && event?.data.noop) { - debug.log(`${INTEGRATION_NAME} Navigation action is a noop, not starting navigation span.`); - return; - } - - if (event && !isAppRestart) { + if (event && !isAppRestart && !event.data?.noop) { addBreadcrumb({ category: 'navigation.dispatch', type: 'navigation', @@ -370,6 +365,11 @@ export const reactNavigationIntegration = ({ }); } + if (useDispatchedActionData && event?.data.noop) { + debug.log(`${INTEGRATION_NAME} Navigation action is a noop, not starting navigation span.`); + return; + } + const navigationActionType = useDispatchedActionData ? actionType : undefined; // Handle PRELOAD actions separately if prefetch tracking is enabled diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 7b3ea3af07..612720c330 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -1750,7 +1750,7 @@ describe('ReactNavigationInstrumentation', () => { ); }); - it('does not create dispatch breadcrumb for noop actions', async () => { + it('does not create dispatch breadcrumb for noop actions when useDispatchedActionData is enabled', async () => { setupTestClient({ useDispatchedActionData: true }); mockNavigation.emitWithoutStateChange({ data: { @@ -1767,6 +1767,23 @@ describe('ReactNavigationInstrumentation', () => { 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();