From 3aa359d505aa295fa00f1d22954895aea729d503 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 19 Nov 2025 14:47:35 +0100 Subject: [PATCH 1/4] fix(tracing): Discard empty Route Change transactions when route is undefined --- .../core/src/js/tracing/onSpanEndUtils.ts | 71 +++++++++++++++++++ .../src/js/tracing/reactnativenavigation.ts | 10 ++- .../core/src/js/tracing/reactnavigation.ts | 10 ++- .../core/test/tracing/reactnavigation.test.ts | 56 +++++++++++++++ .../core/test/tracing/reactnavigationutils.ts | 7 ++ 5 files changed, 152 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/tracing/onSpanEndUtils.ts b/packages/core/src/js/tracing/onSpanEndUtils.ts index d35d9e7379..5e12f13803 100644 --- a/packages/core/src/js/tracing/onSpanEndUtils.ts +++ b/packages/core/src/js/tracing/onSpanEndUtils.ts @@ -87,6 +87,77 @@ export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span }); }; +/** + * Discards empty "Route Change" transactions that never received route information. + * This happens when navigation library emits a route change event but getCurrentRoute() returns undefined. + * Such transactions don't contain any useful information and should not be sent to Sentry. + * + * This function must be called with a reference tracker function that can check if the span + * was cleared from the integration's tracking (indicating it went through the state listener). + */ +export const ignoreEmptyRouteChangeTransactions = ( + client: Client | undefined, + span: Span | undefined, + defaultNavigationSpanName: string, + isSpanStillTracked: () => boolean, +): void => { + if (!client) { + debug.warn('Could not hook on spanEnd event because client is not defined.'); + return; + } + + if (!span) { + debug.warn('Could not hook on spanEnd event because span is not defined.'); + return; + } + + if (!isRootSpan(span) || !isSentrySpan(span)) { + debug.warn('Not sampling empty route change transactions only works for Sentry Transactions (Root Spans).'); + return; + } + + client.on('spanEnd', (endedSpan: Span) => { + if (endedSpan !== span) { + return; + } + + const spanJSON = spanToJSON(span); + + // Only check spans that still have the default navigation name + if (spanJSON.description !== defaultNavigationSpanName) { + return; + } + + // If the span has route information, it went through the normal flow + if (spanJSON.data?.['route.name']) { + return; + } + + // If the span was cleared from tracking, it means the state listener was called + // (even if for same-route navigation), so we should allow it through + if (!isSpanStillTracked()) { + return; + } + + const children = getSpanDescendants(span); + const filtered = children.filter( + child => + child.spanContext().spanId !== span.spanContext().spanId && + spanToJSON(child).op !== 'ui.load.initial_display' && + spanToJSON(child).op !== 'navigation.processing', + ); + + if (filtered.length <= 0) { + // No meaningful child spans and still has default name - this is an empty Route Change transaction + debug.log(`Discarding empty "${defaultNavigationSpanName}" transaction that never received route information.`); + span['_sampled'] = false; + + // Record as dropped transaction for observability + client.recordDroppedEvent('sample_rate', 'transaction'); + } + }); +}; + /** * Idle Transaction callback to only sample transactions with child spans. * To avoid side effects of other callbacks this should be hooked as the last callback. diff --git a/packages/core/src/js/tracing/reactnativenavigation.ts b/packages/core/src/js/tracing/reactnativenavigation.ts index 33a3275fb0..c35f22922c 100644 --- a/packages/core/src/js/tracing/reactnativenavigation.ts +++ b/packages/core/src/js/tracing/reactnativenavigation.ts @@ -9,7 +9,7 @@ import { } from '@sentry/core'; import type { EmitterSubscription } from '../utils/rnlibrariesinterface'; import { isSentrySpan } from '../utils/span'; -import { ignoreEmptyBackNavigation } from './onSpanEndUtils'; +import { ignoreEmptyBackNavigation, ignoreEmptyRouteChangeTransactions } from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NATIVE_NAVIGATION } from './origin'; import type { ReactNativeTracingIntegration } from './reactnativetracing'; import { getReactNativeTracingIntegration } from './reactnativetracing'; @@ -140,6 +140,14 @@ export const reactNativeNavigationIntegration = ({ if (ignoreEmptyBackNavigationTransactions) { ignoreEmptyBackNavigation(getClient(), latestNavigationSpan); } + // Always discard transactions that never receive route information + const spanToCheck = latestNavigationSpan; + ignoreEmptyRouteChangeTransactions( + getClient(), + spanToCheck, + DEFAULT_NAVIGATION_SPAN_NAME, + () => latestNavigationSpan === spanToCheck, + ); stateChangeTimeout = setTimeout(discardLatestNavigationSpan.bind(this), routeChangeTimeoutMs); }; diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index ff5b98f80b..16aa40fd1b 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -17,7 +17,7 @@ import { isSentrySpan } from '../utils/span'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import type { UnsafeAction } from '../vendor/react-navigation/types'; import { NATIVE } from '../wrapper'; -import { ignoreEmptyBackNavigation } from './onSpanEndUtils'; +import { ignoreEmptyBackNavigation, ignoreEmptyRouteChangeTransactions } from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin'; import type { ReactNativeTracingIntegration } from './reactnativetracing'; import { getReactNativeTracingIntegration } from './reactnativetracing'; @@ -252,6 +252,14 @@ export const reactNavigationIntegration = ({ if (ignoreEmptyBackNavigationTransactions) { ignoreEmptyBackNavigation(getClient(), latestNavigationSpan); } + // Always discard transactions that never receive route information + const spanToCheck = latestNavigationSpan; + ignoreEmptyRouteChangeTransactions( + getClient(), + spanToCheck, + DEFAULT_NAVIGATION_SPAN_NAME, + () => latestNavigationSpan === spanToCheck, + ); if (enableTimeToInitialDisplay && latestNavigationSpan) { NATIVE.setActiveSpanId(latestNavigationSpan.spanContext().spanId); diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 0b7ce0a1aa..8f810e3a1b 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -329,6 +329,62 @@ describe('ReactNavigationInstrumentation', () => { ); }); + test('empty Route Change transaction is not sent when route is undefined', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.emitNavigationWithUndefinedRoute(); + jest.runOnlyPendingTimers(); // Flush the empty route change transaction + + await client.flush(); + + // Only the initial transaction should be sent + expect(client.eventQueue.length).toBe(1); + expect(client.event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Initial Screen', + }), + ); + }); + + test('empty Route Change transaction is recorded as dropped', async () => { + const mockRecordDroppedEvent = jest.fn(); + setupTestClient(); + client.recordDroppedEvent = mockRecordDroppedEvent; + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.emitNavigationWithUndefinedRoute(); + jest.runOnlyPendingTimers(); // Flush the empty route change transaction + + await client.flush(); + + // Should have recorded a dropped transaction + expect(mockRecordDroppedEvent).toHaveBeenCalledWith('sample_rate', 'transaction'); + }); + + test('empty Route Change transaction is not sent after multiple undefined routes', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.emitNavigationWithUndefinedRoute(); + jest.runOnlyPendingTimers(); // Flush the first empty route change transaction + + mockNavigation.emitNavigationWithUndefinedRoute(); + jest.runOnlyPendingTimers(); // Flush the second empty route change transaction + + await client.flush(); + + // Only the initial transaction should be sent + expect(client.eventQueue.length).toBe(1); + expect(client.event).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Initial Screen', + }), + ); + }); + describe('navigation container registration', () => { test('registers navigation container object ref', () => { const instrumentation = reactNavigationIntegration(); diff --git a/packages/core/test/tracing/reactnavigationutils.ts b/packages/core/test/tracing/reactnavigationutils.ts index bbc4576859..aa164087f9 100644 --- a/packages/core/test/tracing/reactnavigationutils.ts +++ b/packages/core/test/tracing/reactnavigationutils.ts @@ -68,6 +68,13 @@ export function createMockNavigationAndAttachTo(sut: ReturnType { + mockedNavigationContained.listeners['__unsafe_action__'](navigationAction); + mockedNavigationContained.currentRoute = undefined as any; + mockedNavigationContained.listeners['state']({ + // this object is not used by the instrumentation + }); + }, }; sut.registerNavigationContainer(mockRef(mockedNavigationContained)); From c431d6a14ffbff0c896bb37f35561ce68599547a Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 19 Nov 2025 14:49:46 +0100 Subject: [PATCH 2/4] fix ignore code duplication --- .../core/src/js/tracing/onSpanEndUtils.ts | 131 ++++++++---------- 1 file changed, 60 insertions(+), 71 deletions(-) diff --git a/packages/core/src/js/tracing/onSpanEndUtils.ts b/packages/core/src/js/tracing/onSpanEndUtils.ts index 5e12f13803..07c68ae3fc 100644 --- a/packages/core/src/js/tracing/onSpanEndUtils.ts +++ b/packages/core/src/js/tracing/onSpanEndUtils.ts @@ -43,7 +43,28 @@ export const adjustTransactionDuration = (client: Client, span: Span, maxDuratio }); }; -export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span | undefined): void => { +/** + * Helper function to filter out auto-instrumentation child spans. + */ +function getMeaningfulChildSpans(span: Span): Span[] { + const children = getSpanDescendants(span); + return children.filter( + child => + child.spanContext().spanId !== span.spanContext().spanId && + spanToJSON(child).op !== 'ui.load.initial_display' && + spanToJSON(child).op !== 'navigation.processing', + ); +} + +/** + * Generic helper to discard empty navigation spans based on a condition. + */ +function discardEmptyNavigationSpan( + client: Client | undefined, + span: Span | undefined, + shouldDiscardFn: (span: Span) => boolean, + onDiscardFn: (span: Span) => void, +): void { if (!client) { debug.warn('Could not hook on spanEnd event because client is not defined.'); return; @@ -55,7 +76,7 @@ export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span } if (!isRootSpan(span) || !isSentrySpan(span)) { - debug.warn('Not sampling empty back spans only works for Sentry Transactions (Root Spans).'); + debug.warn('Not sampling empty navigation spans only works for Sentry Transactions (Root Spans).'); return; } @@ -64,27 +85,31 @@ export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span return; } - if (!spanToJSON(span).data?.['route.has_been_seen']) { + if (!shouldDiscardFn(span)) { return; } - const children = getSpanDescendants(span); - const filtered = children.filter( - child => - child.spanContext().spanId !== span.spanContext().spanId && - spanToJSON(child).op !== 'ui.load.initial_display' && - spanToJSON(child).op !== 'navigation.processing', - ); - - if (filtered.length <= 0) { - // filter children must include at least one span not created by the navigation automatic instrumentation - debug.log( - 'Not sampling transaction as route has been seen before. Pass ignoreEmptyBackNavigationTransactions = false to disable this feature.', - ); - // Route has been seen before and has no child spans. + const meaningfulChildren = getMeaningfulChildSpans(span); + if (meaningfulChildren.length <= 0) { + onDiscardFn(span); span['_sampled'] = false; } }); +} + +export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span | undefined): void => { + discardEmptyNavigationSpan( + client, + span, + // Only discard if route has been seen before + span => spanToJSON(span).data?.['route.has_been_seen'] === true, + // Log message when discarding + () => { + debug.log( + 'Not sampling transaction as route has been seen before. Pass ignoreEmptyBackNavigationTransactions = false to disable this feature.', + ); + }, + ); }; /** @@ -101,61 +126,25 @@ export const ignoreEmptyRouteChangeTransactions = ( defaultNavigationSpanName: string, isSpanStillTracked: () => boolean, ): void => { - if (!client) { - debug.warn('Could not hook on spanEnd event because client is not defined.'); - return; - } - - if (!span) { - debug.warn('Could not hook on spanEnd event because span is not defined.'); - return; - } - - if (!isRootSpan(span) || !isSentrySpan(span)) { - debug.warn('Not sampling empty route change transactions only works for Sentry Transactions (Root Spans).'); - return; - } - - client.on('spanEnd', (endedSpan: Span) => { - if (endedSpan !== span) { - return; - } - - const spanJSON = spanToJSON(span); - - // Only check spans that still have the default navigation name - if (spanJSON.description !== defaultNavigationSpanName) { - return; - } - - // If the span has route information, it went through the normal flow - if (spanJSON.data?.['route.name']) { - return; - } - - // If the span was cleared from tracking, it means the state listener was called - // (even if for same-route navigation), so we should allow it through - if (!isSpanStillTracked()) { - return; - } - - const children = getSpanDescendants(span); - const filtered = children.filter( - child => - child.spanContext().spanId !== span.spanContext().spanId && - spanToJSON(child).op !== 'ui.load.initial_display' && - spanToJSON(child).op !== 'navigation.processing', - ); - - if (filtered.length <= 0) { - // No meaningful child spans and still has default name - this is an empty Route Change transaction + discardEmptyNavigationSpan( + client, + span, + // Only discard if: + // 1. Still has default name + // 2. No route information was set + // 3. Still being tracked (state listener never called) + span => { + const spanJSON = spanToJSON(span); + return ( + spanJSON.description === defaultNavigationSpanName && !spanJSON.data?.['route.name'] && isSpanStillTracked() + ); + }, + // Log and record dropped event + _span => { debug.log(`Discarding empty "${defaultNavigationSpanName}" transaction that never received route information.`); - span['_sampled'] = false; - - // Record as dropped transaction for observability - client.recordDroppedEvent('sample_rate', 'transaction'); - } - }); + client?.recordDroppedEvent('sample_rate', 'transaction'); + }, + ); }; /** From b69a153d3ec196c28c46e176e1b6e00f86c79782 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 19 Nov 2025 14:50:53 +0100 Subject: [PATCH 3/4] Adds changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb9e79e27..9326788300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - You can now choose which logs are captured: 'native' for logs from native code only, 'js' for logs from the JavaScript layer only, or 'all' for both layers. - Takes effect only if `enableLogs` is `true` and defaults to 'all', preserving previous behavior. +### Fixes + +- Discard empty Route Change transactions [#5387](https://github.com/getsentry/sentry-react-native/issues/5387)) + ### Dependencies - Bump JavaScript SDK from v10.24.0 to v10.25.0 ([#5362](https://github.com/getsentry/sentry-react-native/pull/5362)) From b4553502ed1b70cbb7741d6125f48f176c0ee13e Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 19 Nov 2025 15:50:18 +0100 Subject: [PATCH 4/4] Fix changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9326788300..ca5a02ea05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ ### Fixes -- Discard empty Route Change transactions [#5387](https://github.com/getsentry/sentry-react-native/issues/5387)) +- Discard empty Route Change transactions ([#5387](https://github.com/getsentry/sentry-react-native/issues/5387)) ### Dependencies