diff --git a/CHANGELOG.md b/CHANGELOG.md index b3513bc460..2d1f43b52c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Fixes - Stop the Hermes sampling profiler on React instance teardown to prevent `pthread_kill` SIGABRT when the JS thread is torn down with profiling active ([#6035](https://github.com/getsentry/sentry-react-native/pull/6035)) +- Discard invalid navigation/interaction transactions via an event processor instead of mutating the internal `_sampled` flag, removing misleading "dropped due to sampling" debug logs ([#6051](https://github.com/getsentry/sentry-react-native/pull/6051)) ### Dependencies diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 382e26ce08..e4b0cd9a6a 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -23,6 +23,7 @@ import { } from '../../measurements'; import { convertSpanToTransaction, isRootSpan, setEndTimeValue } from '../../utils/span'; import { NATIVE } from '../../wrapper'; +import { getRootSpanDiscardReason, getTransactionEventDiscardReason } from '../onSpanEndUtils'; import { APP_START_COLD as APP_START_COLD_OP, APP_START_WARM as APP_START_WARM_OP, @@ -353,14 +354,14 @@ export const appStartIntegration = ({ const recordFirstStartedActiveRootSpanId = (rootSpan: Span): void => { if (firstStartedActiveRootSpanId) { // Check if the previously locked span was dropped after it ended (e.g., by - // ignoreEmptyRouteChangeTransactions or ignoreEmptyBackNavigation setting - // _sampled = false during spanEnd). If so, reset and allow this new span. + // ignoreEmptyRouteChangeTransactions or ignoreEmptyBackNavigation marking + // it for discard during spanEnd). If so, reset and allow this new span. // We check here (at the next spanStart) rather than at spanEnd because // the discard listeners run after the app start listener in registration order, - // so _sampled is not yet false when our own spanEnd listener would fire. - if (firstStartedActiveRootSpan && !spanIsSampled(firstStartedActiveRootSpan)) { + // so the discard attribute is not yet set when our own spanEnd listener would fire. + if (firstStartedActiveRootSpan && getRootSpanDiscardReason(firstStartedActiveRootSpan) !== undefined) { debug.log( - '[AppStart] Previously locked root span was unsampled after ending. Resetting to allow next transaction.', + '[AppStart] Previously locked root span was marked for discard after ending. Resetting to allow next transaction.', ); resetFirstStartedActiveRootSpanId(); // Fall through to lock to this new span @@ -468,6 +469,16 @@ export const appStartIntegration = ({ return; } + // Don't attach (and don't flip the flushed flag) for transactions the + // tracing integration is about to drop — e.g. empty back-navigations, + // route-change spans that never received route info, or childless idle + // spans. Otherwise the next real transaction would be left without app + // start data because `appStartDataFlushed` would already be `true`. + if (getTransactionEventDiscardReason(event)) { + debug.log('[AppStart] Skipping app start attach for transaction marked for discard.'); + return; + } + if (!event.contexts?.trace) { debug.warn('[AppStart] Transaction event is missing trace context. Can not attach app start.'); return; diff --git a/packages/core/src/js/tracing/onSpanEndUtils.ts b/packages/core/src/js/tracing/onSpanEndUtils.ts index 0f1919b34a..aa61b08735 100644 --- a/packages/core/src/js/tracing/onSpanEndUtils.ts +++ b/packages/core/src/js/tracing/onSpanEndUtils.ts @@ -1,4 +1,4 @@ -import type { Client, Span } from '@sentry/core'; +import type { Client, Event, Span } from '@sentry/core'; import type { AppStateStatus } from 'react-native'; import { debug, getSpanDescendants, SPAN_STATUS_ERROR, spanToJSON, timestampInSeconds } from '@sentry/core'; @@ -6,6 +6,50 @@ import { AppState, Platform } from 'react-native'; import { isRootSpan, isSentrySpan } from '../utils/span'; +/** + * Attribute used to mark root spans whose corresponding transaction event + * should be dropped by the React Native tracing event processor instead of + * being treated as sampled-out by `@sentry/core`. + * + * The value is a stable string identifier describing why the SDK chose to + * discard the transaction (e.g. an empty back-navigation, a route-change + * transaction that never received route information, or a childless idle + * transaction). + */ +export const SENTRY_DISCARD_REASON_ATTRIBUTE = 'sentry.rn.discard_reason'; + +export type SentryDiscardReason = + | 'empty_back_navigation' + | 'no_route_info' + | 'no_child_spans' + | 'discarded_latest_navigation'; + +/** + * Marks a root span so its transaction event will be filtered out by the + * tracing integration's event processor. The span itself is left with its + * original sampling decision so debug logs no longer report "dropped due to + * sampling" for SDK-side discards. + */ +export function markRootSpanForDiscard(span: Span, reason: SentryDiscardReason): void { + span.setAttribute(SENTRY_DISCARD_REASON_ATTRIBUTE, reason); +} + +/** + * Returns the SDK-side discard reason recorded on a root span, if any. + */ +export function getRootSpanDiscardReason(span: Span): SentryDiscardReason | undefined { + const value = spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]; + return typeof value === 'string' ? (value as SentryDiscardReason) : undefined; +} + +/** + * Returns the SDK-side discard reason from a transaction event, if any. + */ +export function getTransactionEventDiscardReason(event: Event): SentryDiscardReason | undefined { + const value = event.contexts?.trace?.data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]; + return typeof value === 'string' ? (value as SentryDiscardReason) : undefined; +} + /** * The time to wait after the app enters the `inactive` state on iOS before * cancelling the span. @@ -104,7 +148,6 @@ function discardEmptyNavigationSpan( const meaningfulChildren = getMeaningfulChildSpans(span); if (meaningfulChildren.length <= 0) { onDiscardFn(span); - span['_sampled'] = false; } }); } @@ -115,11 +158,12 @@ export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span span, // Only discard if route has been seen before span => spanToJSON(span).data?.['route.has_been_seen'] === true, - // Log message when discarding - () => { + // Log message and mark the span for discard via the event processor. + span => { debug.log( 'Not sampling transaction as route has been seen before. Pass ignoreEmptyBackNavigationTransactions = false to disable this feature.', ); + markRootSpanForDiscard(span, 'empty_back_navigation'); }, ); }; @@ -151,10 +195,13 @@ export const ignoreEmptyRouteChangeTransactions = ( spanJSON.description === defaultNavigationSpanName && !spanJSON.data?.['route.name'] && isSpanStillTracked() ); }, - // Log and record dropped event - _span => { + // Log and mark the span for discard. The actual `recordDroppedEvent` call + // happens in the tracing integration's event processor with the correct + // `event_processor` reason, so we no longer record it here as a sampling + // drop. + span => { debug.log(`Discarding empty "${defaultNavigationSpanName}" transaction that never received route information.`); - client?.recordDroppedEvent('sample_rate', 'transaction'); + markRootSpanForDiscard(span, 'no_route_info'); }, ); }; @@ -180,7 +227,7 @@ export const onlySampleIfChildSpans = (client: Client, span: Span): void => { if (children.length <= 1) { // Span always has at lest one child, itself debug.log(`Not sampling as ${spanToJSON(span).op} transaction has no child spans.`); - span['_sampled'] = false; + markRootSpanForDiscard(span, 'no_child_spans'); } }); }; diff --git a/packages/core/src/js/tracing/reactnativenavigation.ts b/packages/core/src/js/tracing/reactnativenavigation.ts index 9db9dc00a5..e93909ce8d 100644 --- a/packages/core/src/js/tracing/reactnativenavigation.ts +++ b/packages/core/src/js/tracing/reactnativenavigation.ts @@ -13,7 +13,11 @@ import type { EmitterSubscription } from '../utils/rnlibrariesinterface'; import type { ReactNativeTracingIntegration } from './reactnativetracing'; import { isSentrySpan } from '../utils/span'; -import { ignoreEmptyBackNavigation, ignoreEmptyRouteChangeTransactions } from './onSpanEndUtils'; +import { + ignoreEmptyBackNavigation, + ignoreEmptyRouteChangeTransactions, + markRootSpanForDiscard, +} from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NATIVE_NAVIGATION } from './origin'; import { getReactNativeTracingIntegration } from './reactnativetracing'; import { @@ -221,7 +225,7 @@ export const reactNativeNavigationIntegration = ({ const discardLatestNavigationSpan = (): void => { if (latestNavigationSpan) { if (isSentrySpan(latestNavigationSpan)) { - latestNavigationSpan['_sampled'] = false; + markRootSpanForDiscard(latestNavigationSpan, 'discarded_latest_navigation'); } // TODO: What if it's not SentrySpan? latestNavigationSpan.end(); diff --git a/packages/core/src/js/tracing/reactnativetracing.ts b/packages/core/src/js/tracing/reactnativetracing.ts index bb43196e94..0b2284f3a3 100644 --- a/packages/core/src/js/tracing/reactnativetracing.ts +++ b/packages/core/src/js/tracing/reactnativetracing.ts @@ -1,10 +1,11 @@ -import type { Client, Event, Integration, StartSpanOptions } from '@sentry/core'; +import type { Client, Event, EventHint, Integration, StartSpanOptions } from '@sentry/core'; import { instrumentOutgoingRequests } from '@sentry/browser'; -import { getClient } from '@sentry/core'; +import { debug, getClient } from '@sentry/core'; import { isWeb } from '../utils/environment'; import { getDevServer } from './../integrations/debugsymbolicatorutils'; +import { getTransactionEventDiscardReason } from './onSpanEndUtils'; import { addDefaultOpForSpanFrom, addThreadInfoToSpan, defaultIdleOptions } from './span'; export const INTEGRATION_NAME = 'ReactNativeTracing'; @@ -136,7 +137,18 @@ export const reactNativeTracingIntegration = ( }); }; - const processEvent = (event: Event): Event => { + const processEvent = (event: Event, _hint: EventHint, _client: Client): Event | null => { + if (event.type === 'transaction') { + const discardReason = getTransactionEventDiscardReason(event); + if (discardReason) { + debug.log(`[ReactNativeTracing] Dropping transaction marked for discard (reason: ${discardReason}).`); + // `@sentry/core` automatically records a dropped event with reason + // `event_processor` when a processor returns `null`, so we don't call + // `recordDroppedEvent` here to avoid double-counting in client reports. + return null; + } + } + if (event.contexts && state.currentRoute) { event.contexts.app = { view_names: [state.currentRoute], ...event.contexts.app }; } diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 6af6a245d9..652936cccc 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -21,7 +21,11 @@ import { getAppRegistryIntegration } from '../integrations/appRegistry'; import { isSentrySpan } from '../utils/span'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { NATIVE } from '../wrapper'; -import { ignoreEmptyBackNavigation, ignoreEmptyRouteChangeTransactions } from './onSpanEndUtils'; +import { + ignoreEmptyBackNavigation, + ignoreEmptyRouteChangeTransactions, + markRootSpanForDiscard, +} from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin'; import { getReactNativeTracingIntegration } from './reactnativetracing'; import { SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; @@ -535,7 +539,7 @@ export const reactNavigationIntegration = ({ const _discardLatestTransaction = (): void => { if (latestNavigationSpan) { if (isSentrySpan(latestNavigationSpan)) { - latestNavigationSpan['_sampled'] = false; + markRootSpanForDiscard(latestNavigationSpan, 'discarded_latest_navigation'); } // TODO: What if it's not SentrySpan? latestNavigationSpan.end(); diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 2b76227044..54677da33f 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -491,10 +491,11 @@ describe('App Start Integration', () => { }); // Simulate the span being dropped (e.g., ignoreEmptyRouteChangeTransactions - // sets _sampled = false during spanEnd processing). - // Note: _sampled is a @sentry/core SentrySpan internal — this couples to the - // same mechanism used by onSpanEndUtils.ts (discardEmptyNavigationSpan). - (firstSpan as any)['_sampled'] = false; + // marking the root span via the `sentry.rn.discard_reason` attribute during + // spanEnd processing). The tracing integration's event processor drops the + // resulting transaction; appStart detects the marker on the next spanStart + // and resets the lock so the next root span can attach app start data. + firstSpan.setAttribute('sentry.rn.discard_reason', 'no_route_info'); // Second root span starts — recordFirstStartedActiveRootSpanId detects // the previously locked span is no longer sampled and resets the lock @@ -524,6 +525,61 @@ describe('App Start Integration', () => { expect((actualEvent as TransactionEvent)?.measurements?.[APP_START_COLD_MEASUREMENT]).toBeDefined(); }); + it('Skips app start attachment for discarded transactions and preserves it for the next one', async () => { + mockAppStart({ cold: true }); + + const integration = appStartIntegration(); + const client = new TestClient({ + ...getDefaultTestClientOptions(), + enableAppStartTracking: true, + tracesSampleRate: 1.0, + }); + setCurrentClient(client); + integration.setup(client); + integration.afterAllSetup(client); + + // First root span — gets marked for discard before its transaction event + // reaches the app start integration's processEvent. + const firstSpan = startInactiveSpan({ name: 'First Navigation', forceTransaction: true }); + const firstSpanId = firstSpan.spanContext().spanId; + firstSpan.setAttribute('sentry.rn.discard_reason', 'no_route_info'); + + // Simulate `appStartIntegration` (registered before `reactNativeTracingIntegration`) + // running its processEvent on the discarded transaction. It must not + // attach app start data nor flip `appStartDataFlushed = true`, otherwise + // the next real transaction would lose app start. + const discardedEvent = getMinimalTransactionEvent(); + discardedEvent.contexts!.trace!.span_id = firstSpanId; + discardedEvent.contexts!.trace!.data = { 'sentry.rn.discard_reason': 'no_route_info' }; + + const processedDiscarded = await processEventWithIntegration(integration, discardedEvent); + + // Event passes through unchanged — no app start span attached. + const discardedColdStartSpan = (processedDiscarded as TransactionEvent)?.spans?.find( + ({ description }) => description === 'Cold Start', + ); + expect(discardedColdStartSpan).toBeUndefined(); + expect((processedDiscarded as TransactionEvent)?.measurements?.[APP_START_COLD_MEASUREMENT]).toBeUndefined(); + + // Next real root span starts — its discard marker on the previous span + // resets the lock and the new span gets locked. + const secondSpan = startInactiveSpan({ name: 'Second Navigation', forceTransaction: true }); + const secondSpanId = secondSpan.spanContext().spanId; + + const realEvent = getMinimalTransactionEvent(); + realEvent.contexts!.trace!.span_id = secondSpanId; + + const actualEvent = await processEventWithIntegration(integration, realEvent); + + // App start data is still available because the discarded event did not + // flip `appStartDataFlushed`. + const appStartSpan = (actualEvent as TransactionEvent)?.spans?.find( + ({ description }) => description === 'Cold Start', + ); + expect(appStartSpan).toBeDefined(); + expect((actualEvent as TransactionEvent)?.measurements?.[APP_START_COLD_MEASUREMENT]).toBeDefined(); + }); + it('Does not lock firstStartedActiveRootSpanId to unsampled root span', async () => { mockAppStart({ cold: true }); diff --git a/packages/core/test/tracing/onSpanEndUtils.test.ts b/packages/core/test/tracing/onSpanEndUtils.test.ts index 05f717a895..f35d667ae4 100644 --- a/packages/core/test/tracing/onSpanEndUtils.test.ts +++ b/packages/core/test/tracing/onSpanEndUtils.test.ts @@ -1,6 +1,6 @@ import type { Client, Span } from '@sentry/core'; -import { getClient, startSpanManual } from '@sentry/core'; +import { getClient, spanToJSON, startSpan, startSpanManual } from '@sentry/core'; import { adjustTransactionDuration, @@ -9,6 +9,7 @@ import { ignoreEmptyRouteChangeTransactions, onlySampleIfChildSpans, onThisSpanEnd, + SENTRY_DISCARD_REASON_ATTRIBUTE, } from '../../src/js/tracing/onSpanEndUtils'; import { setupTestClient } from '../mocks/client'; @@ -131,6 +132,41 @@ describe('onSpanEndUtils', () => { span.end(); expect(getUnsubscribeCount()).toBe(1); }); + + it('marks the span for discard without mutating sampling', () => { + const client = getClient()!; + const span = createRootSpan('target') as Span & { _sampled?: boolean }; + span.setAttribute('route.has_been_seen', true); + + ignoreEmptyBackNavigation(client, span); + span.end(); + + expect(spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBe('empty_back_navigation'); + expect(span._sampled).not.toBe(false); + }); + + it('does not mark the span when the route has not been seen', () => { + const client = getClient()!; + const span = createRootSpan('target'); + + ignoreEmptyBackNavigation(client, span); + span.end(); + + expect(spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBeUndefined(); + }); + + it('does not mark the span when meaningful child spans exist', () => { + const client = getClient()!; + let parent: Span | undefined; + startSpan({ name: 'parent', forceTransaction: true }, span => { + parent = span; + span.setAttribute('route.has_been_seen', true); + ignoreEmptyBackNavigation(client, span); + startSpan({ name: 'meaningful child' }, () => undefined); + }); + + expect(spanToJSON(parent!).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBeUndefined(); + }); }); describe('ignoreEmptyRouteChangeTransactions', () => { @@ -145,6 +181,28 @@ describe('onSpanEndUtils', () => { span.end(); expect(getUnsubscribeCount()).toBe(1); }); + + it('marks the span for discard when the route name is missing', () => { + const client = getClient()!; + const span = createRootSpan('Route Change') as Span & { _sampled?: boolean }; + + ignoreEmptyRouteChangeTransactions(client, span, 'Route Change', () => true); + span.end(); + + expect(spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBe('no_route_info'); + expect(span._sampled).not.toBe(false); + }); + + it('does not mark the span when route info has been received', () => { + const client = getClient()!; + const span = createRootSpan('Route Change'); + span.setAttribute('route.name', 'Home'); + + ignoreEmptyRouteChangeTransactions(client, span, 'Route Change', () => true); + span.end(); + + expect(spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBeUndefined(); + }); }); describe('onlySampleIfChildSpans', () => { @@ -159,6 +217,17 @@ describe('onSpanEndUtils', () => { span.end(); expect(getUnsubscribeCount()).toBe(1); }); + + it('marks childless root spans for discard', () => { + const client = getClient()!; + const span = createRootSpan('target') as Span & { _sampled?: boolean }; + + onlySampleIfChildSpans(client, span); + span.end(); + + expect(spanToJSON(span).data?.[SENTRY_DISCARD_REASON_ATTRIBUTE]).toBe('no_child_spans'); + expect(span._sampled).not.toBe(false); + }); }); describe('cancelInBackground', () => { diff --git a/packages/core/test/tracing/reactnativetracing.test.ts b/packages/core/test/tracing/reactnativetracing.test.ts index 688be37e18..987fb27d15 100644 --- a/packages/core/test/tracing/reactnativetracing.test.ts +++ b/packages/core/test/tracing/reactnativetracing.test.ts @@ -142,4 +142,73 @@ describe('ReactNativeTracing', () => { expect(processedEvent).toEqual(expectedEvent); }); }); + + describe('discarded transaction event processor', () => { + it('drops transaction events marked with the discard reason attribute', () => { + const integration = reactNativeTracingIntegration(); + const recordDroppedEvent = jest.spyOn(client, 'recordDroppedEvent'); + + const event: Event = { + type: 'transaction', + contexts: { + trace: { + trace_id: 'a'.repeat(32), + span_id: 'b'.repeat(16), + data: { 'sentry.rn.discard_reason': 'no_route_info' }, + }, + }, + }; + + const processedEvent = integration.processEvent(event, {}, client); + + expect(processedEvent).toBeNull(); + // `@sentry/core` records the `event_processor` drop automatically when + // a processor returns `null`, so the integration must not call + // `recordDroppedEvent` itself (would double-count in client reports). + expect(recordDroppedEvent).not.toHaveBeenCalled(); + }); + + it('does not drop transaction events without the discard reason attribute', () => { + const integration = reactNativeTracingIntegration(); + const recordDroppedEvent = jest.spyOn(client, 'recordDroppedEvent'); + + const event: Event = { + type: 'transaction', + contexts: { + trace: { + trace_id: 'a'.repeat(32), + span_id: 'b'.repeat(16), + data: { 'route.name': 'Home' }, + }, + }, + }; + + const processedEvent = integration.processEvent(event, {}, client); + + expect(processedEvent).not.toBeNull(); + expect(recordDroppedEvent).not.toHaveBeenCalled(); + }); + + it('does not drop non-transaction events even if marked', () => { + const integration = reactNativeTracingIntegration(); + const recordDroppedEvent = jest.spyOn(client, 'recordDroppedEvent'); + + // Errors should never carry this attribute, but the processor should + // still pass them through unchanged if they happen to. + const event: Event = { + contexts: { + trace: { + trace_id: 'a'.repeat(32), + span_id: 'b'.repeat(16), + data: { 'sentry.rn.discard_reason': 'no_route_info' }, + }, + }, + }; + + const processedEvent = integration.processEvent(event, {}, client); + + expect(processedEvent).not.toBeNull(); + expect(recordDroppedEvent).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 1b6ed70600..aef0614df7 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -407,8 +407,9 @@ describe('ReactNavigationInstrumentation', () => { await client.flush(); - // Should have recorded a dropped transaction - expect(mockRecordDroppedEvent).toHaveBeenCalledWith('sample_rate', 'transaction'); + // Should have recorded a dropped transaction via the tracing integration's + // event processor (no longer reported as a sampling drop). + expect(mockRecordDroppedEvent).toHaveBeenCalledWith('event_processor', 'transaction'); }); test('empty Route Change transaction is not sent after multiple undefined routes', async () => { @@ -533,6 +534,7 @@ describe('ReactNavigationInstrumentation', () => { await jest.runOnlyPendingTimersAsync(); + expect(spanToJSON(mockTransaction).data?.['sentry.rn.discard_reason']).toBeUndefined(); expect(mockTransaction['_sampled']).not.toBe(false); }); @@ -787,10 +789,17 @@ describe('ReactNavigationInstrumentation', () => { expect(mockTransaction['_sampled']).toBe(true); expect(mockTransaction['_name']).toBe(DEFAULT_NAVIGATION_SPAN_NAME); + expect(spanToJSON(mockTransaction).data?.['sentry.rn.discard_reason']).toBeUndefined(); jest.advanceTimersByTime(20); - expect(mockTransaction['_sampled']).toBe(false); + // After the routeChangeTimeout, the SDK marks the span for discard via a + // dedicated attribute (instead of mutating the private `_sampled` flag) + // so the tracing integration's event processor drops it. + // `ignoreEmptyRouteChangeTransactions` runs after `_discardLatestTransaction` + // and overwrites the reason with the more specific `no_route_info`. + expect(mockTransaction['_sampled']).toBe(true); + expect(spanToJSON(mockTransaction).data?.['sentry.rn.discard_reason']).toBe('no_route_info'); }); });