From 643c544ccadab6c3a62ef0c02c50c0c624da28fd Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 14 Aug 2024 09:58:05 +0200 Subject: [PATCH] ref: Navigation Integrations with new function style (#4003) --- CHANGELOG.md | 38 ++ samples/expo/app/_layout.tsx | 9 +- samples/react-native/src/App.tsx | 15 +- src/js/client.ts | 14 - src/js/index.ts | 11 +- src/js/tracing/index.ts | 13 +- src/js/tracing/integrations/appStart.ts | 11 +- src/js/tracing/onSpanEndUtils.ts | 17 +- src/js/tracing/reactnativenavigation.ts | 234 ++++---- src/js/tracing/reactnativetracing.ts | 72 +-- src/js/tracing/reactnavigation.ts | 513 +++++++++--------- src/js/tracing/routingInstrumentation.ts | 81 --- src/js/tracing/span.ts | 71 ++- test/client.test.ts | 25 - test/sdk.test.ts | 9 +- test/tracing/gesturetracing.test.ts | 10 +- test/tracing/idleNavigationSpan.test.ts | 82 +++ .../integrations/userInteraction.test.ts | 15 +- test/tracing/mockedrountinginstrumention.ts | 24 - test/tracing/reactnativenavigation.test.ts | 17 +- test/tracing/reactnativetracing.test.ts | 189 +------ .../reactnavigation.stalltracking.test.ts | 8 +- test/tracing/reactnavigation.test.ts | 58 +- test/tracing/reactnavigation.ttid.test.tsx | 14 +- test/tracing/reactnavigationutils.ts | 4 +- 25 files changed, 681 insertions(+), 873 deletions(-) delete mode 100644 src/js/tracing/routingInstrumentation.ts create mode 100644 test/tracing/idleNavigationSpan.test.ts delete mode 100644 test/tracing/mockedrountinginstrumention.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 607efaadf..a5fb9c0a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,44 @@ }); ``` +- New React Navigation Integration interface ([#4003](https://github.com/getsentry/sentry-react-native/pull/4003)) + + ```js + import Sentry from '@sentry/react-native'; + import { NavigationContainer } from '@react-navigation/native'; + + const reactNavigationIntegration = Sentry.reactNavigationIntegration(); + + Sentry.init({ + tracesSampleRate: 1.0, + integrations: [reactNavigationIntegration], + }); + + function RootComponent() { + const navigation = React.useRef(null); + + return { + reactNavigationIntegration.registerNavigationContainer(navigation); + }}> + ; + } + ``` + +- New React Native Navigation Integration interface ([#4003](https://github.com/getsentry/sentry-react-native/pull/4003)) + + ```js + import Sentry from '@sentry/react-native'; + import { Navigation } from 'react-native-navigation'; + + Sentry.init({ + tracesSampleRate: 1.0, + integrations: [ + Sentry.reactNativeNavigationIntegration({ navigation: Navigation }) + ], + }); + ``` + ### Features - `TimeToInitialDisplay` and `TimeToFullDisplay` start the time to display spans on mount ([#4020](https://github.com/getsentry/sentry-react-native/pull/4020)) diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index 539dbb0fa..bdb9ed49c 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -21,7 +21,7 @@ LogBox.ignoreAllLogs(); // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); -const routingInstrumentation = new Sentry.ReactNavigationInstrumentation({ +const navigationIntegration = Sentry.reactNavigationIntegration({ enableTimeToInitialDisplay: !isExpoGo(), // This is not supported in Expo Go. }); @@ -54,9 +54,8 @@ process.env.EXPO_SKIP_DURING_EXPORT !== 'true' && Sentry.init({ // default: [/.*/] failedRequestTargets: [/.*/], }), - Sentry.reactNativeTracingIntegration({ - routingInstrumentation, - }), + navigationIntegration, + Sentry.reactNativeTracingIntegration(), ); return integrations.filter(i => i.name !== 'Dedupe'); }, @@ -91,7 +90,7 @@ function RootLayout() { useEffect(() => { if (ref) { - routingInstrumentation.registerNavigationContainer(ref); + navigationIntegration.registerNavigationContainer(ref); } }, [ref]); diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 167b40476..0ad4c1e1f 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -38,11 +38,11 @@ LogBox.ignoreAllLogs(); const isMobileOs = Platform.OS === 'android' || Platform.OS === 'ios'; -const reactNavigationInstrumentation = - new Sentry.ReactNavigationInstrumentation({ - routeChangeTimeoutMs: 500, // How long it will wait for the route change to complete. Default is 1000ms - enableTimeToInitialDisplay: isMobileOs, - }); +const reactNavigationIntegration = Sentry.reactNavigationIntegration({ + routeChangeTimeoutMs: 500, // How long it will wait for the route change to complete. Default is 1000ms + enableTimeToInitialDisplay: isMobileOs, + ignoreEmptyBackNavigationTransactions: true, +}); Sentry.init({ // Replace the example DSN below with your own DSN: @@ -66,11 +66,10 @@ Sentry.init({ }, integrations(integrations) { integrations.push( + reactNavigationIntegration, Sentry.reactNativeTracingIntegration({ // The time to wait in ms until the transaction will be finished, For testing, default is 1000 ms idleTimeoutMs: 5_000, - routingInstrumentation: reactNavigationInstrumentation, - ignoreEmptyBackNavigationTransactions: true, }), Sentry.httpClientIntegration({ // These options are effective only in JS. @@ -183,7 +182,7 @@ function BottomTabs() { { - reactNavigationInstrumentation.registerNavigationContainer(navigation); + reactNavigationIntegration.registerNavigationContainer(navigation); }}> { this._initNativeSdk(); } - /** - * @inheritdoc - */ - protected _setupIntegrations(): void { - super._setupIntegrations(); - const tracing = getReactNativeTracingIntegration(this); - const routingName = tracing?.options?.routingInstrumentation?.name; - if (routingName) { - this.addIntegration(createIntegration(routingName)); - } - } - /** * Starts native client with dsn and options */ diff --git a/src/js/index.ts b/src/js/index.ts index 8854f7371..6b6071874 100644 --- a/src/js/index.ts +++ b/src/js/index.ts @@ -59,15 +59,18 @@ export { TouchEventBoundary, withTouchEventBoundary } from './touchevents'; export { reactNativeTracingIntegration, - ReactNavigationV5Instrumentation, - ReactNavigationInstrumentation, - ReactNativeNavigationInstrumentation, - RoutingInstrumentation, + getCurrentReactNativeTracingIntegration, + getReactNativeTracingIntegration, + reactNavigationIntegration, + reactNativeNavigationIntegration, sentryTraceGesture, TimeToInitialDisplay, TimeToFullDisplay, startTimeToInitialDisplaySpan, startTimeToFullDisplaySpan, + startIdleNavigationSpan, + startIdleSpan, + getDefaultIdleNavigationSpanOptions, } from './tracing'; export type { TimeToDisplayProps } from './tracing'; diff --git a/src/js/tracing/index.ts b/src/js/tracing/index.ts index dc071fe23..446d2b82f 100644 --- a/src/js/tracing/index.ts +++ b/src/js/tracing/index.ts @@ -1,18 +1,15 @@ export { reactNativeTracingIntegration, INTEGRATION_NAME as REACT_NATIVE_TRACING_INTEGRATION_NAME, + getCurrentReactNativeTracingIntegration, + getReactNativeTracingIntegration, } from './reactnativetracing'; export type { ReactNativeTracingIntegration } from './reactnativetracing'; -export type { RoutingInstrumentationInstance } from './routingInstrumentation'; -export { RoutingInstrumentation } from './routingInstrumentation'; +export { reactNavigationIntegration } from './reactnavigation'; +export { reactNativeNavigationIntegration } from './reactnativenavigation'; -export { - ReactNavigationInstrumentation, - // eslint-disable-next-line deprecation/deprecation - ReactNavigationV5Instrumentation, -} from './reactnavigation'; -export { ReactNativeNavigationInstrumentation } from './reactnativenavigation'; +export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions } from './span'; export type { ReactNavigationCurrentRoute, ReactNavigationRoute } from './types'; diff --git a/src/js/tracing/integrations/appStart.ts b/src/js/tracing/integrations/appStart.ts index 875507f9c..3b1365b04 100644 --- a/src/js/tracing/integrations/appStart.ts +++ b/src/js/tracing/integrations/appStart.ts @@ -22,7 +22,6 @@ import { APP_START_WARM as APP_START_WARM_OP, UI_LOAD as UI_LOAD_OP, } from '../ops'; -import { getReactNativeTracingIntegration } from '../reactnativetracing'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes'; import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs } from '../utils'; @@ -97,7 +96,7 @@ export function _clearRootComponentCreationTimestampMs(): void { * Adds AppStart spans from the native layer to the transaction event. */ export const appStartIntegration = ({ - standalone: standaloneUserOption, + standalone = false, }: { /** * Should the integration send App Start as a standalone root span (transaction)? @@ -108,7 +107,6 @@ export const appStartIntegration = ({ standalone?: boolean; } = {}): AppStartIntegration => { let _client: Client | undefined = undefined; - let standalone = standaloneUserOption; let isEnabled = true; let appStartDataFlushed = false; @@ -123,11 +121,8 @@ export const appStartIntegration = ({ } }; - const afterAllSetup = (client: Client): void => { - if (standaloneUserOption === undefined) { - // If not user defined, set based on the routing instrumentation presence - standalone = !getReactNativeTracingIntegration(client)?.options.routingInstrumentation; - } + const afterAllSetup = (_client: Client): void => { + // TODO: automatically set standalone based on the presence of the native layer navigation integration }; const processEvent = async (event: Event): Promise => { diff --git a/src/js/tracing/onSpanEndUtils.ts b/src/js/tracing/onSpanEndUtils.ts index 90c2acbe0..993d22c40 100644 --- a/src/js/tracing/onSpanEndUtils.ts +++ b/src/js/tracing/onSpanEndUtils.ts @@ -7,7 +7,7 @@ import { AppState } from 'react-native'; import { isRootSpan, isSentrySpan } from '../utils/span'; /** - * + * Hooks on span end event to execute a callback when the span ends. */ export function onThisSpanEnd(client: Client, span: Span, callback: (span: Span) => void): void { client.on('spanEnd', (endedSpan: Span) => { @@ -44,7 +44,18 @@ export const adjustTransactionDuration = (client: Client, span: Span, maxDuratio } }); }; -export const ignoreEmptyBackNavigation = (client: Client, span: Span): void => { + +export const ignoreEmptyBackNavigation = (client: Client | undefined, span: Span): void => { + if (!client) { + logger.warn('Could not hook on spanEnd event because client is not defined.'); + return; + } + + if (!span) { + logger.warn('Could not hook on spanEnd event because span is not defined.'); + return; + } + if (!isRootSpan(span) || !isSentrySpan(span)) { logger.warn('Not sampling empty back spans only works for Sentry Transactions (Root Spans).'); return; @@ -70,7 +81,7 @@ export const ignoreEmptyBackNavigation = (client: Client, span: Span): void => { if (filtered.length <= 0) { // filter children must include at least one span not created by the navigation automatic instrumentation logger.log( - '[ReactNativeTracing] Not sampling transaction as route has been seen before. Pass ignoreEmptyBackNavigationTransactions = false to disable this feature.', + '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. span['_sampled'] = false; diff --git a/src/js/tracing/reactnativenavigation.ts b/src/js/tracing/reactnativenavigation.ts index b07d6caff..3f0378d96 100644 --- a/src/js/tracing/reactnativenavigation.ts +++ b/src/js/tracing/reactnativenavigation.ts @@ -1,40 +1,61 @@ import { addBreadcrumb, + getClient, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, } from '@sentry/core'; -import type { Span } from '@sentry/types'; +import type { Client, Integration, Span } from '@sentry/types'; import type { EmitterSubscription } from '../utils/rnlibrariesinterface'; import { isSentrySpan } from '../utils/span'; -import { DEFAULT_NAVIGATION_SPAN_NAME } from './reactnativetracing'; -import type { OnConfirmRoute, TransactionCreator } from './routingInstrumentation'; -import { InternalRoutingInstrumentation } from './routingInstrumentation'; -import type { BeforeNavigate } from './types'; +import { ignoreEmptyBackNavigation } from './onSpanEndUtils'; +import type { ReactNativeTracingIntegration } from './reactnativetracing'; +import { getReactNativeTracingIntegration } from './reactnativetracing'; +import { + DEFAULT_NAVIGATION_SPAN_NAME, + defaultIdleOptions, + getDefaultIdleNavigationSpanOptions, + startIdleNavigationSpan as startGenericIdleNavigationSpan, +} from './span'; + +export const INTEGRATION_NAME = 'ReactNativeNavigation'; + +const NAVIGATION_HISTORY_MAX_SIZE = 200; interface ReactNativeNavigationOptions { /** * How long the instrumentation will wait for the route to mount after a change has been initiated, * before the transaction is discarded. - * Time is in ms. * - * Default: 1000 + * @default 1_000 (ms) */ - routeChangeTimeoutMs: number; + routeChangeTimeoutMs?: number; + /** * Instrumentation will create a transaction on tab change. * By default only navigation commands create transactions. * - * Default: true + * @default false */ - enableTabsInstrumentation: boolean; -} + enableTabsInstrumentation?: boolean; -const defaultOptions: ReactNativeNavigationOptions = { - routeChangeTimeoutMs: 1000, - enableTabsInstrumentation: true, -}; + /** + * Does not sample transactions that are from routes that have been seen any more and don't have any spans. + * This removes a lot of the clutter as most back navigation transactions are now ignored. + * + * @default true + */ + ignoreEmptyBackNavigationTransactions?: boolean; + + /** The React Native Navigation `NavigationDelegate`. + * + * ```js + * import { Navigation } from 'react-native-navigation'; + * ``` + */ + navigation: unknown; +} interface ComponentEvent { componentId: string; @@ -74,142 +95,135 @@ export interface NavigationDelegate { * - `_onComponentWillAppear` is then called AFTER the state change happens due to a dispatch and sets the route context onto the active transaction. * - If `_onComponentWillAppear` isn't called within `options.routeChangeTimeoutMs` of the dispatch, then the transaction is not sampled and finished. */ -export class ReactNativeNavigationInstrumentation extends InternalRoutingInstrumentation { - public static instrumentationName: string = 'react-native-navigation'; - - public readonly name: string = ReactNativeNavigationInstrumentation.instrumentationName; - - private _navigation: NavigationDelegate; - private _options: ReactNativeNavigationOptions; - - private _prevComponentEvent: ComponentWillAppearEvent | null = null; - - private _latestTransaction?: Span; - private _recentComponentIds: string[] = []; - private _stateChangeTimeout?: number | undefined; - - public constructor( - /** The react native navigation `NavigationDelegate`. This is usually the import named `Navigation`. */ - navigation: unknown, - options: Partial = {}, - ) { - super(); - - this._navigation = navigation as NavigationDelegate; - - this._options = { - ...defaultOptions, - ...options, - }; - } - - /** - * Registers the event listeners for React Native Navigation - */ - public registerRoutingInstrumentation( - listener: TransactionCreator, - beforeNavigate: BeforeNavigate, - onConfirmRoute: OnConfirmRoute, - ): void { - super.registerRoutingInstrumentation(listener, beforeNavigate, onConfirmRoute); - - this._navigation.events().registerCommandListener(this._onNavigation.bind(this)); - - if (this._options.enableTabsInstrumentation) { - this._navigation.events().registerBottomTabPressedListener(this._onNavigation.bind(this)); +export const reactNativeNavigationIntegration = ({ + navigation: optionsNavigation, + routeChangeTimeoutMs = 1_000, + enableTabsInstrumentation = false, + ignoreEmptyBackNavigationTransactions = true, +}: ReactNativeNavigationOptions): Integration => { + const navigation = optionsNavigation as NavigationDelegate; + let recentComponentIds: string[] = []; + + let tracing: ReactNativeTracingIntegration | undefined; + let idleSpanOptions: Parameters[1] = defaultIdleOptions; + + let stateChangeTimeout: ReturnType | undefined; + let prevComponentEvent: ComponentWillAppearEvent | null = null; + let latestNavigationSpan: Span | undefined; + + const afterAllSetup = (client: Client): void => { + tracing = getReactNativeTracingIntegration(client); + if (tracing) { + idleSpanOptions = { + finalTimeout: tracing.options.finalTimeoutMs, + idleTimeout: tracing.options.idleTimeoutMs, + }; } + }; - this._navigation.events().registerComponentWillAppearListener(this._onComponentWillAppear.bind(this)); - } - - /** - * To be called when a navigation is initiated. (Command, BottomTabSelected, etc.) - */ - private _onNavigation(): void { - if (this._latestTransaction) { - this._discardLatestTransaction(); + const startIdleNavigationSpan = (): void => { + if (latestNavigationSpan) { + discardLatestNavigationSpan(); } - this._latestTransaction = this.onRouteWillChange({ name: DEFAULT_NAVIGATION_SPAN_NAME }); - - this._stateChangeTimeout = setTimeout( - this._discardLatestTransaction.bind(this), - this._options.routeChangeTimeoutMs, + latestNavigationSpan = startGenericIdleNavigationSpan( + tracing && tracing.options.beforeStartSpan + ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) + : getDefaultIdleNavigationSpanOptions(), + idleSpanOptions, ); - } + if (ignoreEmptyBackNavigationTransactions) { + ignoreEmptyBackNavigation(getClient(), latestNavigationSpan); + } - /** - * To be called AFTER the state has been changed to populate the transaction with the current route. - */ - private _onComponentWillAppear(event: ComponentWillAppearEvent): void { - if (!this._latestTransaction) { + stateChangeTimeout = setTimeout(discardLatestNavigationSpan.bind(this), routeChangeTimeoutMs); + }; + + const updateLatestNavigationSpanWithCurrentComponent = (event: ComponentWillAppearEvent): void => { + if (!latestNavigationSpan) { return; } // We ignore actions that pertain to the same screen. - const isSameComponent = this._prevComponentEvent && event.componentId === this._prevComponentEvent.componentId; + const isSameComponent = prevComponentEvent && event.componentId === prevComponentEvent.componentId; if (isSameComponent) { - this._discardLatestTransaction(); + discardLatestNavigationSpan(); return; } - this._clearStateChangeTimeout(); + clearStateChangeTimeout(); - const routeHasBeenSeen = this._recentComponentIds.includes(event.componentId); + const routeHasBeenSeen = recentComponentIds.includes(event.componentId); - if (spanToJSON(this._latestTransaction).description === DEFAULT_NAVIGATION_SPAN_NAME) { - this._latestTransaction.updateName(event.componentName); + if (spanToJSON(latestNavigationSpan).description === DEFAULT_NAVIGATION_SPAN_NAME) { + latestNavigationSpan.updateName(event.componentName); } - this._latestTransaction.setAttributes({ + latestNavigationSpan.setAttributes({ // TODO: Should we include pass props? I don't know exactly what it contains, cant find it in the RNavigation docs 'route.name': event.componentName, 'route.component_id': event.componentId, 'route.component_type': event.componentType, 'route.has_been_seen': routeHasBeenSeen, - 'previous_route.name': this._prevComponentEvent?.componentName, - 'previous_route.component_id': this._prevComponentEvent?.componentId, - 'previous_route.component_type': this._prevComponentEvent?.componentType, + 'previous_route.name': prevComponentEvent?.componentName, + 'previous_route.component_id': prevComponentEvent?.componentId, + 'previous_route.component_type': prevComponentEvent?.componentType, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }); - this._beforeNavigate?.(this._latestTransaction); - - this._onConfirmRoute?.(event.componentName); + tracing?.setCurrentRoute(event.componentName); addBreadcrumb({ category: 'navigation', type: 'navigation', message: `Navigation to ${event.componentName}`, data: { - from: this._prevComponentEvent?.componentName, + from: prevComponentEvent?.componentName, to: event.componentName, }, }); - this._prevComponentEvent = event; - this._latestTransaction = undefined; + pushRecentComponentId(event.componentId); + prevComponentEvent = event; + latestNavigationSpan = undefined; + }; + + navigation.events().registerCommandListener(startIdleNavigationSpan); + if (enableTabsInstrumentation) { + navigation.events().registerBottomTabPressedListener(startIdleNavigationSpan); } + navigation.events().registerComponentWillAppearListener(updateLatestNavigationSpanWithCurrentComponent); + + const pushRecentComponentId = (id: string): void => { + recentComponentIds.push(id); + + if (recentComponentIds.length > NAVIGATION_HISTORY_MAX_SIZE) { + recentComponentIds = recentComponentIds.slice(recentComponentIds.length - NAVIGATION_HISTORY_MAX_SIZE); + } + }; - /** Cancels the latest transaction so it does not get sent to Sentry. */ - private _discardLatestTransaction(): void { - if (this._latestTransaction) { - if (isSentrySpan(this._latestTransaction)) { - this._latestTransaction['_sampled'] = false; + const discardLatestNavigationSpan = (): void => { + if (latestNavigationSpan) { + if (isSentrySpan(latestNavigationSpan)) { + latestNavigationSpan['_sampled'] = false; } // TODO: What if it's not SentrySpan? - this._latestTransaction.end(); - this._latestTransaction = undefined; + latestNavigationSpan.end(); + latestNavigationSpan = undefined; } - this._clearStateChangeTimeout(); - } + clearStateChangeTimeout(); + }; - /** Cancels the latest transaction so it does not get sent to Sentry. */ - private _clearStateChangeTimeout(): void { - if (typeof this._stateChangeTimeout !== 'undefined') { - clearTimeout(this._stateChangeTimeout); - this._stateChangeTimeout = undefined; + const clearStateChangeTimeout = (): void => { + if (typeof stateChangeTimeout !== 'undefined') { + clearTimeout(stateChangeTimeout); + stateChangeTimeout = undefined; } - } -} + }; + + return { + name: INTEGRATION_NAME, + afterAllSetup, + }; +}; diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index d4397c2a7..7e66b5d30 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -1,11 +1,9 @@ /* eslint-disable max-lines */ import { instrumentOutgoingRequests } from '@sentry/browser'; -import { getClient, getCurrentScope } from '@sentry/core'; +import { getClient } from '@sentry/core'; import type { Client, Event, Integration, StartSpanOptions } from '@sentry/types'; -import { logger } from '@sentry/utils'; -import type { RoutingInstrumentationInstance } from './routingInstrumentation'; -import { addDefaultOpForSpanFrom, startIdleNavigationSpan } from './span'; +import { addDefaultOpForSpanFrom, defaultIdleOptions } from './span'; export const INTEGRATION_NAME = 'ReactNativeTracing'; @@ -16,7 +14,7 @@ export interface ReactNativeTracingOptions { * * @default 1_000 (ms) */ - idleTimeoutMs: number; + idleTimeoutMs?: number; /** * The max. time an idle span may run. @@ -24,7 +22,7 @@ export interface ReactNativeTracingOptions { * * @default 60_0000 (ms) */ - finalTimeoutMs: number; + finalTimeoutMs?: number; /** * Flag to disable patching all together for fetch requests. @@ -47,20 +45,6 @@ export interface ReactNativeTracingOptions { */ enableHTTPTimings: boolean; - /** - * The routing instrumentation to be used with the tracing integration. - * There is no routing instrumentation if nothing is passed. - */ - routingInstrumentation?: RoutingInstrumentationInstance; - - /** - * Does not sample transactions that are from routes that have been seen any more and don't have any spans. - * This removes a lot of the clutter as most back navigation transactions are now ignored. - * - * @default true - */ - ignoreEmptyBackNavigationTransactions: boolean; - /** * A callback which is called before a span for a navigation is started. * It receives the options passed to `startSpan`, and expects to return an updated options object. @@ -77,18 +61,14 @@ export interface ReactNativeTracingOptions { } const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; -export const DEFAULT_NAVIGATION_SPAN_NAME = 'Route Change'; -const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { - idleTimeoutMs: 1_000, - finalTimeoutMs: 60_0000, +export const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { traceFetch: true, traceXHR: true, enableHTTPTimings: true, - ignoreEmptyBackNavigationTransactions: true, }; -type ReactNativeTracingState = { +export type ReactNativeTracingState = { currentRoute: string | undefined; }; @@ -97,6 +77,7 @@ export const reactNativeTracingIntegration = ( ): Integration & { options: ReactNativeTracingOptions; state: ReactNativeTracingState; + setCurrentRoute: (route: string) => void; } => { const state: ReactNativeTracingState = { currentRoute: undefined, @@ -106,8 +87,8 @@ export const reactNativeTracingIntegration = ( ...defaultReactNativeTracingOptions, ...options, beforeStartSpan: options.beforeStartSpan ?? ((options: StartSpanOptions) => options), - finalTimeoutMs: options.finalTimeoutMs ?? defaultReactNativeTracingOptions.finalTimeoutMs, - idleTimeoutMs: options.idleTimeoutMs ?? defaultReactNativeTracingOptions.idleTimeoutMs, + finalTimeoutMs: options.finalTimeoutMs ?? defaultIdleOptions.finalTimeout, + idleTimeoutMs: options.idleTimeoutMs ?? defaultIdleOptions.idleTimeout, }; const setup = (client: Client): void => { @@ -121,37 +102,6 @@ export const reactNativeTracingIntegration = ( }); }; - const afterAllSetup = (): void => { - if (finalOptions.routingInstrumentation) { - const idleNavigationSpanOptions = { - finalTimeout: finalOptions.finalTimeoutMs, - idleTimeout: finalOptions.idleTimeoutMs, - ignoreEmptyBackNavigationTransactions: finalOptions.ignoreEmptyBackNavigationTransactions, - }; - finalOptions.routingInstrumentation.registerRoutingInstrumentation( - navigationInstrumentationOptions => - startIdleNavigationSpan( - finalOptions.beforeStartSpan({ - name: DEFAULT_NAVIGATION_SPAN_NAME, - op: 'navigation', - forceTransaction: true, - scope: getCurrentScope(), - ...navigationInstrumentationOptions, - }), - idleNavigationSpanOptions, - ), - () => { - // no-op, replaced by beforeStartSpan, will be removed in the future - }, - (currentViewName: string | undefined) => { - state.currentRoute = currentViewName; - }, - ); - } else { - logger.log(`[${INTEGRATION_NAME}] Not instrumenting route changes as routingInstrumentation has not been set.`); - } - }; - const processEvent = (event: Event): Event => { if (event.contexts && state.currentRoute) { event.contexts.app = { view_names: [state.currentRoute], ...event.contexts.app }; @@ -162,10 +112,12 @@ export const reactNativeTracingIntegration = ( return { name: INTEGRATION_NAME, setup, - afterAllSetup, processEvent, options: finalOptions, state, + setCurrentRoute: (route: string) => { + state.currentRoute = route; + }, }; }; diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index fbe460b9e..7948af888 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -2,46 +2,43 @@ import { addBreadcrumb, getActiveSpan, + getClient, SEMANTIC_ATTRIBUTE_SENTRY_OP, SPAN_STATUS_OK, spanToJSON, startInactiveSpan, } from '@sentry/core'; -import type { Span } from '@sentry/types'; -import { logger, timestampInSeconds } from '@sentry/utils'; +import type { Client, Integration, Span } from '@sentry/types'; +import { isPlainObject, logger, timestampInSeconds } from '@sentry/utils'; import type { NewFrameEvent } from '../utils/sentryeventemitter'; import { type SentryEventEmitter, createSentryEventEmitter, NewFrameEventName } from '../utils/sentryeventemitter'; import { isSentrySpan } from '../utils/span'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { NATIVE } from '../wrapper'; -import { DEFAULT_NAVIGATION_SPAN_NAME } from './reactnativetracing'; -import type { OnConfirmRoute, TransactionCreator } from './routingInstrumentation'; -import { InternalRoutingInstrumentation } from './routingInstrumentation'; +import { ignoreEmptyBackNavigation } from './onSpanEndUtils'; +import type { ReactNativeTracingIntegration } from './reactnativetracing'; +import { getReactNativeTracingIntegration } from './reactnativetracing'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; +import { + DEFAULT_NAVIGATION_SPAN_NAME, + defaultIdleOptions, + getDefaultIdleNavigationSpanOptions, + startIdleNavigationSpan as startGenericIdleNavigationSpan, +} from './span'; import { manualInitialDisplaySpans, startTimeToInitialDisplaySpan } from './timetodisplay'; -import type { BeforeNavigate } from './types'; import { setSpanDurationAsMeasurementOnSpan } from './utils'; -export interface NavigationRoute { - name: string; - key: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - params?: Record; -} +export const INTEGRATION_NAME = 'ReactNavigation'; -interface NavigationContainer { - addListener: (type: string, listener: () => void) => void; - getCurrentRoute: () => NavigationRoute; -} +const NAVIGATION_HISTORY_MAX_SIZE = 200; -interface ReactNavigationOptions { +interface ReactNavigationIntegrationOptions { /** * How long the instrumentation will wait for the route to mount after a change has been initiated, * before the transaction is discarded. - * Time is in ms. * - * @default 1000 + * @default 1_000 (ms) */ routeChangeTimeoutMs: number; @@ -52,12 +49,15 @@ interface ReactNavigationOptions { * @default false */ enableTimeToInitialDisplay: boolean; -} -const defaultOptions: ReactNavigationOptions = { - routeChangeTimeoutMs: 1000, - enableTimeToInitialDisplay: false, -}; + /** + * Does not sample transactions that are from routes that have been seen any more and don't have any spans. + * This removes a lot of the clutter as most back navigation transactions are now ignored. + * + * @default true + */ + ignoreEmptyBackNavigationTransactions: boolean; +} /** * Instrumentation for React-Navigation V5 and above. See docs or sample app for usage. @@ -67,293 +67,294 @@ const defaultOptions: ReactNavigationOptions = { * - `_onStateChange` is then called AFTER the state change happens due to a dispatch and sets the route context onto the active transaction. * - If `_onStateChange` isn't called within `STATE_CHANGE_TIMEOUT_DURATION` of the dispatch, then the transaction is not sampled and finished. */ -export class ReactNavigationInstrumentation extends InternalRoutingInstrumentation { - public static instrumentationName: string = 'react-navigation-v5'; - - public readonly name: string = ReactNavigationInstrumentation.instrumentationName; - - private _navigationContainer: NavigationContainer | null = null; - private _newScreenFrameEventEmitter: SentryEventEmitter | null = null; - - private readonly _maxRecentRouteLen: number = 200; - - private _latestRoute?: NavigationRoute; - private _latestTransaction?: Span; - private _navigationProcessingSpan?: Span; - - private _initialStateHandled: boolean = false; - private _stateChangeTimeout?: number | undefined; - private _recentRouteKeys: string[] = []; +export const reactNavigationIntegration = ({ + routeChangeTimeoutMs = 1_000, + enableTimeToInitialDisplay = false, + ignoreEmptyBackNavigationTransactions = true, +}: Partial = {}): Integration & { + /** + * Pass the ref to the navigation container to register it to the instrumentation + * @param navigationContainerRef Ref to a `NavigationContainer` + */ + registerNavigationContainer: (navigationContainerRef: unknown) => void; +} => { + let navigationContainer: NavigationContainer | undefined; + let newScreenFrameEventEmitter: SentryEventEmitter | undefined; + + let tracing: ReactNativeTracingIntegration | undefined; + let idleSpanOptions: Parameters[1] = defaultIdleOptions; + let latestRoute: NavigationRoute | undefined; + + let latestNavigationSpan: Span | undefined; + let navigationProcessingSpan: Span | undefined; + + let initialStateHandled: boolean = false; + let stateChangeTimeout: ReturnType | undefined; + let recentRouteKeys: string[] = []; + + if (enableTimeToInitialDisplay) { + newScreenFrameEventEmitter = createSentryEventEmitter(); + newScreenFrameEventEmitter.initAsync(NewFrameEventName); + NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => { + logger.error(`${INTEGRATION_NAME} Failed to initialize native new frame tracking: ${reason}`); + }); + } - private _options: ReactNavigationOptions; + /** + * Set the initial state and start initial navigation span for the current screen. + */ + const afterAllSetup = (client: Client): void => { + tracing = getReactNativeTracingIntegration(client); + if (tracing) { + idleSpanOptions = { + finalTimeout: tracing.options.finalTimeoutMs, + idleTimeout: tracing.options.idleTimeoutMs, + }; + } - public constructor(options: Partial = {}) { - super(); + if (initialStateHandled) { + // We create an initial state here to ensure a transaction gets created before the first route mounts. + return undefined; + } - this._options = { - ...defaultOptions, - ...options, - }; + startIdleNavigationSpan(); - if (this._options.enableTimeToInitialDisplay) { - this._newScreenFrameEventEmitter = createSentryEventEmitter(); - this._newScreenFrameEventEmitter.initAsync(NewFrameEventName); - NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => { - logger.error(`[ReactNavigationInstrumentation] Failed to initialize native new frame tracking: ${reason}`); - }); + if (!navigationContainer) { + // This is expected as navigation container is registered after the root component is mounted. + return undefined; } - } - /** - * Extends by calling _handleInitialState at the end. - */ - public registerRoutingInstrumentation( - listener: TransactionCreator, - beforeNavigate: BeforeNavigate, - onConfirmRoute: OnConfirmRoute, - ): void { - super.registerRoutingInstrumentation(listener, beforeNavigate, onConfirmRoute); - - // We create an initial state here to ensure a transaction gets created before the first route mounts. - if (!this._initialStateHandled) { - this._onDispatch(); - if (this._navigationContainer) { - // Navigation container already registered, just populate with route state - this._onStateChange(); - - this._initialStateHandled = true; - } - } - } + // Navigation container already registered, just populate with route state + updateLatestNavigationSpanWithCurrentRoute(); + initialStateHandled = true; + }; - /** - * Pass the ref to the navigation container to register it to the instrumentation - * @param navigationContainerRef Ref to a `NavigationContainer` - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types - public registerNavigationContainer(navigationContainerRef: any): void { + const registerNavigationContainer = (navigationContainerRef: unknown): void => { /* We prevent duplicate routing instrumentation to be initialized on fast refreshes Explanation: If the user triggers a fast refresh on the file that the instrumentation is initialized in, it will initialize a new instance and will cause undefined behavior. */ - if (!RN_GLOBAL_OBJ.__sentry_rn_v5_registered) { - if ('current' in navigationContainerRef) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this._navigationContainer = navigationContainerRef.current; - } else { - this._navigationContainer = navigationContainerRef; - } - - if (this._navigationContainer) { - this._navigationContainer.addListener( - '__unsafe_action__', // This action is emitted on every dispatch - this._onDispatch.bind(this), - ); - this._navigationContainer.addListener( - 'state', // This action is emitted on every state change - this._onStateChange.bind(this), - ); - - if (!this._initialStateHandled) { - if (this._latestTransaction) { - // If registerRoutingInstrumentation was called first _onDispatch has already been called - this._onStateChange(); - - this._initialStateHandled = true; - } else { - logger.log( - '[ReactNavigationInstrumentation] Navigation container registered, but integration has not been setup yet.', - ); - } - } - - RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true; - } else { - logger.warn('[ReactNavigationInstrumentation] Received invalid navigation container ref!'); - } - } else { + if (RN_GLOBAL_OBJ.__sentry_rn_v5_registered) { logger.log( - '[ReactNavigationInstrumentation] Instrumentation already exists, but register has been called again, doing nothing.', + `${INTEGRATION_NAME} Instrumentation already exists, but register has been called again, doing nothing.`, ); + return undefined; + } + + if (isPlainObject(navigationContainerRef) && 'current' in navigationContainerRef) { + navigationContainer = navigationContainerRef.current as NavigationContainer; + } else { + navigationContainer = navigationContainerRef as NavigationContainer; + } + if (!navigationContainer) { + logger.warn(`${INTEGRATION_NAME} Received invalid navigation container ref!`); + return undefined; + } + + // This action is emitted on every dispatch + navigationContainer.addListener('__unsafe_action__', startIdleNavigationSpan); + navigationContainer.addListener('state', updateLatestNavigationSpanWithCurrentRoute); + RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true; + + if (initialStateHandled) { + return undefined; + } + + if (!latestNavigationSpan) { + logger.log(`${INTEGRATION_NAME} Navigation container registered, but integration has not been setup yet.`); + return undefined; } - } + + // Navigation Container is registered after the first navigation + // Initial navigation span was started, after integration setup, + // so now we populate it with the current route. + updateLatestNavigationSpanWithCurrentRoute(); + initialStateHandled = true; + }; /** * To be called on every React-Navigation action dispatch. * It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change - * and gets the route information from there, @see _onStateChange + * and gets the route information from there, @see updateLatestNavigationSpanWithCurrentRoute */ - private _onDispatch(): void { - if (this._latestTransaction) { - logger.log( - '[ReactNavigationInstrumentation] A transaction was detected that turned out to be a noop, discarding.', - ); - this._discardLatestTransaction(); - this._clearStateChangeTimeout(); + const startIdleNavigationSpan = (): void => { + if (latestNavigationSpan) { + logger.log(`${INTEGRATION_NAME} A transaction was detected that turned out to be a noop, discarding.`); + _discardLatestTransaction(); + clearStateChangeTimeout(); } - this._latestTransaction = this.onRouteWillChange({ name: DEFAULT_NAVIGATION_SPAN_NAME }); + latestNavigationSpan = startGenericIdleNavigationSpan( + tracing && tracing.options.beforeStartSpan + ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) + : getDefaultIdleNavigationSpanOptions(), + idleSpanOptions, + ); + if (ignoreEmptyBackNavigationTransactions) { + ignoreEmptyBackNavigation(getClient(), latestNavigationSpan); + } - if (this._options.enableTimeToInitialDisplay) { - this._navigationProcessingSpan = startInactiveSpan({ + if (enableTimeToInitialDisplay) { + navigationProcessingSpan = startInactiveSpan({ op: 'navigation.processing', name: 'Navigation processing', - startTime: this._latestTransaction && spanToJSON(this._latestTransaction).start_timestamp, + startTime: latestNavigationSpan && spanToJSON(latestNavigationSpan).start_timestamp, }); } - this._stateChangeTimeout = setTimeout( - this._discardLatestTransaction.bind(this), - this._options.routeChangeTimeoutMs, - ); - } + stateChangeTimeout = setTimeout(_discardLatestTransaction, routeChangeTimeoutMs); + }; /** * To be called AFTER the state has been changed to populate the transaction with the current route. */ - private _onStateChange(): void { + const updateLatestNavigationSpanWithCurrentRoute = (): void => { const stateChangedTimestamp = timestampInSeconds(); + const previousRoute = latestRoute; - // Use the getCurrentRoute method to be accurate. - const previousRoute = this._latestRoute; + if (!navigationContainer) { + logger.warn(`${INTEGRATION_NAME} Missing navigation container ref. Route transactions will not be sent.`); + return undefined; + } - if (!this._navigationContainer) { - logger.warn( - '[ReactNavigationInstrumentation] Missing navigation container ref. Route transactions will not be sent.', + const route = navigationContainer.getCurrentRoute(); + if (!route) { + logger.debug(`[${INTEGRATION_NAME}] Navigation state changed, but no route is rendered.`); + return undefined; + } + + if (!latestNavigationSpan) { + logger.debug( + `[${INTEGRATION_NAME}] Navigation state changed, but navigation transaction was not started on dispatch.`, ); + return undefined; + } - return; + if (previousRoute && previousRoute.key === route.key) { + logger.debug(`[${INTEGRATION_NAME}] Navigation state changed, but route is the same as previous.`); + pushRecentRouteKey(route.key); + latestRoute = route; + + // Clear the latest transaction as it has been handled. + latestNavigationSpan = undefined; + return undefined; } - const route = this._navigationContainer.getCurrentRoute(); - - if (route) { - if (this._latestTransaction) { - if (!previousRoute || previousRoute.key !== route.key) { - const routeHasBeenSeen = this._recentRouteKeys.includes(route.key); - const latestTransaction = this._latestTransaction; - const latestTtidSpan = - !routeHasBeenSeen && - this._options.enableTimeToInitialDisplay && - startTimeToInitialDisplaySpan({ - name: `${route.name} initial display`, - isAutoInstrumented: true, - }); - - !routeHasBeenSeen && - latestTtidSpan && - this._newScreenFrameEventEmitter?.once( - NewFrameEventName, - ({ newFrameTimestampInSeconds }: NewFrameEvent) => { - const activeSpan = getActiveSpan(); - if (activeSpan && manualInitialDisplaySpans.has(activeSpan)) { - logger.warn( - '[ReactNavigationInstrumentation] Detected manual instrumentation for the current active span.', - ); - return; - } - - latestTtidSpan.setStatus({ code: SPAN_STATUS_OK }); - latestTtidSpan.end(newFrameTimestampInSeconds); - setSpanDurationAsMeasurementOnSpan('time_to_initial_display', latestTtidSpan, latestTransaction); - }, - ); - - this._navigationProcessingSpan?.updateName(`Processing navigation to ${route.name}`); - this._navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK }); - this._navigationProcessingSpan?.end(stateChangedTimestamp); - this._navigationProcessingSpan = undefined; - - if (spanToJSON(this._latestTransaction).description === DEFAULT_NAVIGATION_SPAN_NAME) { - this._latestTransaction.updateName(route.name); - } - this._latestTransaction.setAttributes({ - 'route.name': route.name, - 'route.key': route.key, - // TODO: filter PII params instead of dropping them all - // 'route.params': {}, - 'route.has_been_seen': routeHasBeenSeen, - 'previous_route.name': previousRoute?.name, - 'previous_route.key': previousRoute?.key, - // TODO: filter PII params instead of dropping them all - // 'previous_route.params': {}, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - }); - - // Clear the timeout so the transaction does not get cancelled. - this._clearStateChangeTimeout(); - - this._onConfirmRoute?.(route.name); - - // TODO: Add test for addBreadcrumb - addBreadcrumb({ - category: 'navigation', - type: 'navigation', - message: `Navigation to ${route.name}`, - data: { - from: previousRoute?.name, - to: route.name, - }, - }); + const routeHasBeenSeen = recentRouteKeys.includes(route.key); + + const latestTtidSpan = + !routeHasBeenSeen && + enableTimeToInitialDisplay && + startTimeToInitialDisplaySpan({ + name: `${route.name} initial display`, + isAutoInstrumented: true, + }); + + const navigationSpanWithTtid = latestNavigationSpan; + !routeHasBeenSeen && + latestTtidSpan && + newScreenFrameEventEmitter?.once(NewFrameEventName, ({ newFrameTimestampInSeconds }: NewFrameEvent) => { + const activeSpan = getActiveSpan(); + if (activeSpan && manualInitialDisplaySpans.has(activeSpan)) { + logger.warn('[ReactNavigationInstrumentation] Detected manual instrumentation for the current active span.'); + return; } - this._pushRecentRouteKey(route.key); - this._latestRoute = route; + latestTtidSpan.setStatus({ code: SPAN_STATUS_OK }); + latestTtidSpan.end(newFrameTimestampInSeconds); + setSpanDurationAsMeasurementOnSpan('time_to_initial_display', latestTtidSpan, navigationSpanWithTtid); + }); - // Clear the latest transaction as it has been handled. - this._latestTransaction = undefined; - } + navigationProcessingSpan?.updateName(`Processing navigation to ${route.name}`); + navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK }); + navigationProcessingSpan?.end(stateChangedTimestamp); + navigationProcessingSpan = undefined; + + if (spanToJSON(latestNavigationSpan).description === DEFAULT_NAVIGATION_SPAN_NAME) { + latestNavigationSpan.updateName(route.name); } - } + latestNavigationSpan.setAttributes({ + 'route.name': route.name, + 'route.key': route.key, + // TODO: filter PII params instead of dropping them all + // 'route.params': {}, + 'route.has_been_seen': routeHasBeenSeen, + 'previous_route.name': previousRoute?.name, + 'previous_route.key': previousRoute?.key, + // TODO: filter PII params instead of dropping them all + // 'previous_route.params': {}, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }); + + // Clear the timeout so the transaction does not get cancelled. + clearStateChangeTimeout(); + + addBreadcrumb({ + category: 'navigation', + type: 'navigation', + message: `Navigation to ${route.name}`, + data: { + from: previousRoute?.name, + to: route.name, + }, + }); + + tracing?.setCurrentRoute(route.key); + + pushRecentRouteKey(route.key); + latestRoute = route; + // Clear the latest transaction as it has been handled. + latestNavigationSpan = undefined; + }; /** Pushes a recent route key, and removes earlier routes when there is greater than the max length */ - private _pushRecentRouteKey = (key: string): void => { - this._recentRouteKeys.push(key); + const pushRecentRouteKey = (key: string): void => { + recentRouteKeys.push(key); - if (this._recentRouteKeys.length > this._maxRecentRouteLen) { - this._recentRouteKeys = this._recentRouteKeys.slice(this._recentRouteKeys.length - this._maxRecentRouteLen); + if (recentRouteKeys.length > NAVIGATION_HISTORY_MAX_SIZE) { + recentRouteKeys = recentRouteKeys.slice(recentRouteKeys.length - NAVIGATION_HISTORY_MAX_SIZE); } }; /** Cancels the latest transaction so it does not get sent to Sentry. */ - private _discardLatestTransaction(): void { - if (this._latestTransaction) { - if (isSentrySpan(this._latestTransaction)) { - this._latestTransaction['_sampled'] = false; + const _discardLatestTransaction = (): void => { + if (latestNavigationSpan) { + if (isSentrySpan(latestNavigationSpan)) { + latestNavigationSpan['_sampled'] = false; } // TODO: What if it's not SentrySpan? - this._latestTransaction.end(); - this._latestTransaction = undefined; + latestNavigationSpan.end(); + latestNavigationSpan = undefined; } - if (this._navigationProcessingSpan) { - this._navigationProcessingSpan = undefined; + if (navigationProcessingSpan) { + navigationProcessingSpan = undefined; } - } + }; - /** - * - */ - private _clearStateChangeTimeout(): void { - if (typeof this._stateChangeTimeout !== 'undefined') { - clearTimeout(this._stateChangeTimeout); - this._stateChangeTimeout = undefined; + const clearStateChangeTimeout = (): void => { + if (typeof stateChangeTimeout !== 'undefined') { + clearTimeout(stateChangeTimeout); + stateChangeTimeout = undefined; } - } -} + }; -/** - * Backwards compatibility alias for ReactNavigationInstrumentation - * @deprecated Use ReactNavigationInstrumentation - */ -export const ReactNavigationV5Instrumentation = ReactNavigationInstrumentation; - -export const BLANK_TRANSACTION_CONTEXT = { - name: 'Route Change', - op: 'navigation', - tags: { - 'routing.instrumentation': ReactNavigationInstrumentation.instrumentationName, - }, - data: {}, + return { + name: INTEGRATION_NAME, + afterAllSetup, + registerNavigationContainer, + }; }; + +export interface NavigationRoute { + name: string; + key: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: Record; +} + +interface NavigationContainer { + addListener: (type: string, listener: () => void) => void; + getCurrentRoute: () => NavigationRoute; +} diff --git a/src/js/tracing/routingInstrumentation.ts b/src/js/tracing/routingInstrumentation.ts deleted file mode 100644 index 865574349..000000000 --- a/src/js/tracing/routingInstrumentation.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { Span, StartSpanOptions } from '@sentry/types'; - -import type { BeforeNavigate } from './types'; - -export type TransactionCreator = (context: StartSpanOptions) => Span | undefined; - -export type OnConfirmRoute = (currentViewName: string | undefined) => void; - -export interface RoutingInstrumentationInstance { - /** - * Name of the routing instrumentation - */ - readonly name: string; - /** - * Registers a listener that's called on every route change with a `TransactionContext`. - * - * Do not overwrite this unless you know what you are doing. - * - * @param listener A `RouteListener` - * @param beforeNavigate BeforeNavigate - * @param inConfirmRoute OnConfirmRoute - */ - registerRoutingInstrumentation( - listener: TransactionCreator, - beforeNavigate: BeforeNavigate, - onConfirmRoute: OnConfirmRoute, - ): void; - /** - * To be called when the route changes, BEFORE the new route mounts. - * If this is called after a route mounts the child spans will not be correctly attached. - * - * @param context A `TransactionContext` used to initialize the transaction. - */ - onRouteWillChange(context: StartSpanOptions): Span | undefined; -} - -/** - * Base Routing Instrumentation. Can be used by users to manually instrument custom routers. - * Pass this to the tracing integration, and call `onRouteWillChange` every time before a route changes. - */ -export class RoutingInstrumentation implements RoutingInstrumentationInstance { - public static instrumentationName: string = 'base-routing-instrumentation'; - - public readonly name: string = RoutingInstrumentation.instrumentationName; - - protected _beforeNavigate?: BeforeNavigate; - protected _onConfirmRoute?: OnConfirmRoute; - protected _tracingListener?: TransactionCreator; - - /** @inheritdoc */ - public registerRoutingInstrumentation( - listener: TransactionCreator, - beforeNavigate: BeforeNavigate, - onConfirmRoute: OnConfirmRoute, - ): void { - this._tracingListener = listener; - this._beforeNavigate = beforeNavigate; - this._onConfirmRoute = onConfirmRoute; - } - - /** @inheritdoc */ - public onRouteWillChange(context: StartSpanOptions): Span | undefined { - const transaction = this._tracingListener?.(context); - - if (transaction) { - this._onConfirmRoute?.(context.name); - } - - return transaction; - } -} - -/** - * Internal base routing instrumentation where `_onConfirmRoute` is not called in onRouteWillChange - */ -export class InternalRoutingInstrumentation extends RoutingInstrumentation { - /** @inheritdoc */ - public onRouteWillChange(context: StartSpanOptions): Span | undefined { - return this._tracingListener?.(context); - } -} diff --git a/src/js/tracing/span.ts b/src/js/tracing/span.ts index f73707ccf..1a9abeb0a 100644 --- a/src/js/tracing/span.ts +++ b/src/js/tracing/span.ts @@ -12,48 +12,69 @@ import type { Client, Scope, Span, StartSpanOptions } from '@sentry/types'; import { generatePropagationContext, logger } from '@sentry/utils'; import { isRootSpan } from '../utils/span'; -import { adjustTransactionDuration, cancelInBackground, ignoreEmptyBackNavigation } from './onSpanEndUtils'; +import { adjustTransactionDuration, cancelInBackground } from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_INTERACTION } from './origin'; +export const DEFAULT_NAVIGATION_SPAN_NAME = 'Route Change'; + +export const defaultIdleOptions: { + /** + * The time that has to pass without any span being created. + * If this time is exceeded, the idle span will finish. + * + * @default 1_000 (ms) + */ + finalTimeout: number; + + /** + * The max. time an idle span may run. + * If this time is exceeded, the idle span will finish no matter what. + * + * @default 60_0000 (ms) + */ + idleTimeout: number; +} = { + idleTimeout: 1_000, + finalTimeout: 60_0000, +}; + export const startIdleNavigationSpan = ( startSpanOption: StartSpanOptions, { - finalTimeout, - idleTimeout, - ignoreEmptyBackNavigationTransactions, - }: { - finalTimeout: number; - idleTimeout: number; - ignoreEmptyBackNavigationTransactions: boolean; - }, + finalTimeout = defaultIdleOptions.finalTimeout, + idleTimeout = defaultIdleOptions.idleTimeout, + }: Partial = {}, ): Span | undefined => { const client = getClient(); if (!client) { - logger.warn(`[ReactNativeTracing] Can't create route change span, missing client.`); + logger.warn(`[startIdleNavigationSpan] Can't create route change span, missing client.`); return undefined; } const activeSpan = getActiveSpan(); if (activeSpan && isRootSpan(activeSpan) && isSentryInteractionSpan(activeSpan)) { logger.log( - `[ReactNativeTracing] Canceling ${spanToJSON(activeSpan).op} transaction because of a new navigation root span.`, + `[startIdleNavigationSpan] Canceling ${ + spanToJSON(activeSpan).op + } transaction because of a new navigation root span.`, ); activeSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); activeSpan.end(); } - const idleSpan = startIdleSpan(startSpanOption, { finalTimeout, idleTimeout }); + const finalStartStapOptions = { + ...getDefaultIdleNavigationSpanOptions(), + ...startSpanOption, + }; + + const idleSpan = startIdleSpan(finalStartStapOptions, { finalTimeout, idleTimeout }); logger.log( - `[ReactNativeTracing] Starting ${startSpanOption.op || 'unknown op'} transaction "${ - startSpanOption.name + `[startIdleNavigationSpan] Starting ${finalStartStapOptions.op || 'unknown op'} transaction "${ + finalStartStapOptions.name }" on scope`, ); adjustTransactionDuration(client, idleSpan, finalTimeout); - if (ignoreEmptyBackNavigationTransactions) { - ignoreEmptyBackNavigation(client, idleSpan); - } - return idleSpan; }; @@ -70,7 +91,7 @@ export const startIdleSpan = ( ): Span => { const client = getClient(); if (!client) { - logger.warn(`[ReactNativeTracing] Can't create idle span, missing client.`); + logger.warn(`[startIdleSpan] Can't create idle span, missing client.`); return new SentryNonRecordingSpan(); } @@ -81,6 +102,18 @@ export const startIdleSpan = ( return span; }; +/** + * Returns the default options for the idle navigation span. + */ +export function getDefaultIdleNavigationSpanOptions(): StartSpanOptions { + return { + name: DEFAULT_NAVIGATION_SPAN_NAME, + op: 'navigation', + forceTransaction: true, + scope: getCurrentScope(), + }; +} + /** * Checks if the span is a Sentry User Interaction span. */ diff --git a/test/client.test.ts b/test/client.test.ts index d4a057b31..dee71e776 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -8,8 +8,6 @@ import * as RN from 'react-native'; import { ReactNativeClient } from '../src/js/client'; import type { ReactNativeClientOptions } from '../src/js/options'; -import type { RoutingInstrumentationInstance } from '../src/js/tracing'; -import { reactNativeTracingIntegration } from '../src/js/tracing'; import { NativeTransport } from '../src/js/transports/native'; import { SDK_NAME, SDK_PACKAGE_NAME, SDK_VERSION } from '../src/js/version'; import { NATIVE } from '../src/js/wrapper'; @@ -609,29 +607,6 @@ describe('Tests ReactNativeClient', () => { client.recordDroppedEvent('before_send', 'error'); } }); - - describe('register enabled instrumentation as integrations', () => { - test('register routing instrumentation', () => { - const mockRoutingInstrumentation: RoutingInstrumentationInstance = { - registerRoutingInstrumentation: jest.fn(), - onRouteWillChange: jest.fn(), - name: 'MockRoutingInstrumentation', - }; - const client = new ReactNativeClient( - mockedOptions({ - dsn: EXAMPLE_DSN, - integrations: [ - reactNativeTracingIntegration({ - routingInstrumentation: mockRoutingInstrumentation, - }), - ], - }), - ); - client.init(); - - expect(client.getIntegrationByName('MockRoutingInstrumentation')).toBeTruthy(); - }); - }); }); function mockedOptions(options: Partial): ReactNativeClientOptions { diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 1c56a9d46..1e89412e4 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -13,11 +13,7 @@ import { logger } from '@sentry/utils'; import { init, withScope } from '../src/js/sdk'; import type { ReactNativeTracingIntegration } from '../src/js/tracing'; -import { - REACT_NATIVE_TRACING_INTEGRATION_NAME, - reactNativeTracingIntegration, - ReactNavigationInstrumentation, -} from '../src/js/tracing'; +import { REACT_NATIVE_TRACING_INTEGRATION_NAME, reactNativeTracingIntegration } from '../src/js/tracing'; import { makeNativeTransport } from '../src/js/transports/native'; import { getDefaultEnvironment, isExpoGo, notWeb } from '../src/js/utils/environment'; import { NATIVE } from './mockWrapper'; @@ -36,8 +32,7 @@ describe('Tests the SDK functionality', () => { describe('init', () => { describe('enableAutoPerformanceTracing', () => { const reactNavigationInstrumentation = (): ReactNativeTracingIntegration => { - const nav = new ReactNavigationInstrumentation(); - return reactNativeTracingIntegration({ routingInstrumentation: nav }); + return reactNativeTracingIntegration(); }; it('Auto Performance is disabled by default', () => { diff --git a/test/tracing/gesturetracing.test.ts b/test/tracing/gesturetracing.test.ts index d0086827b..515146615 100644 --- a/test/tracing/gesturetracing.test.ts +++ b/test/tracing/gesturetracing.test.ts @@ -11,8 +11,6 @@ import { startUserInteractionSpan } from '../../src/js/tracing/integrations/user import type { ReactNativeTracingIntegration } from '../../src/js/tracing/reactnativetracing'; import { reactNativeTracingIntegration } from '../../src/js/tracing/reactnativetracing'; import { type TestClient, setupTestClient } from '../mocks/client'; -import type { MockedRoutingInstrumentation } from './mockedrountinginstrumention'; -import { createMockedRoutingInstrumentation } from './mockedrountinginstrumention'; jest.mock('../../src/js/wrapper', () => { return { @@ -51,7 +49,6 @@ describe('GestureTracing', () => { describe('traces gestures', () => { let client: TestClient; let tracing: ReactNativeTracingIntegration; - let mockedRoutingInstrumentation: MockedRoutingInstrumentation; let mockedGesture: MockGesture; beforeEach(() => { @@ -60,12 +57,9 @@ describe('GestureTracing', () => { client = setupTestClient({ enableUserInteractionTracing: true, }); - mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); - tracing = reactNativeTracingIntegration({ - routingInstrumentation: mockedRoutingInstrumentation, - }); + tracing = reactNativeTracingIntegration(); client.addIntegration(tracing); - mockedRoutingInstrumentation.registeredOnConfirmRoute!('mockedScreenName'); + tracing.setCurrentRoute('mockedScreenName'); mockedGesture = { handlers: { onBegin: jest.fn(), diff --git a/test/tracing/idleNavigationSpan.test.ts b/test/tracing/idleNavigationSpan.test.ts new file mode 100644 index 000000000..0d00907fa --- /dev/null +++ b/test/tracing/idleNavigationSpan.test.ts @@ -0,0 +1,82 @@ +import { getActiveSpan, spanToJSON } from '@sentry/core'; +import type { AppState, AppStateStatus } from 'react-native'; + +import { startIdleNavigationSpan } from '../../src/js/tracing/span'; +import { NATIVE } from '../../src/js/wrapper'; +import { setupTestClient } from '../mocks/client'; + +type MockAppState = { + setState: (state: AppStateStatus) => void; + listener: (newState: AppStateStatus) => void; + removeSubscription: jest.Func; +}; +const mockedAppState: AppState & MockAppState = { + removeSubscription: jest.fn(), + listener: jest.fn(), + isAvailable: true, + currentState: 'active', + addEventListener: (_, listener) => { + mockedAppState.listener = listener; + return { + remove: mockedAppState.removeSubscription, + }; + }, + setState: (state: AppStateStatus) => { + mockedAppState.currentState = state; + mockedAppState.listener(state); + }, +}; +jest.mock('react-native/Libraries/AppState/AppState', () => mockedAppState); + +describe('startIdleNavigationSpan', () => { + beforeEach(() => { + jest.useFakeTimers(); + NATIVE.enableNative = true; + mockedAppState.isAvailable = true; + mockedAppState.addEventListener = (_, listener) => { + mockedAppState.listener = listener; + return { + remove: mockedAppState.removeSubscription, + }; + }; + setupTestClient(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('Cancels route transaction when app goes to background', async () => { + const routeTransaction = startIdleNavigationSpan({ + name: 'test', + }); + + mockedAppState.setState('background'); + + jest.runAllTimers(); + + expect(routeTransaction).toBeDefined(); + expect(spanToJSON(routeTransaction!).status).toBe('cancelled'); + expect(mockedAppState.removeSubscription).toBeCalledTimes(1); + }); + + it('Does not crash when AppState is not available', async () => { + mockedAppState.isAvailable = false; + mockedAppState.addEventListener = ((): void => { + return undefined; + }) as unknown as (typeof mockedAppState)['addEventListener']; // RN Web can return undefined + + startIdleNavigationSpan({ + name: 'test', + }); + + await jest.advanceTimersByTimeAsync(500); + const transaction = getActiveSpan(); + + jest.runAllTimers(); + + expect(spanToJSON(transaction!).timestamp).toBeDefined(); + }); +}); diff --git a/test/tracing/integrations/userInteraction.test.ts b/test/tracing/integrations/userInteraction.test.ts index 01bcd86a0..3ec8905a3 100644 --- a/test/tracing/integrations/userInteraction.test.ts +++ b/test/tracing/integrations/userInteraction.test.ts @@ -15,11 +15,10 @@ import { } from '../../../src/js/tracing/integrations/userInteraction'; import type { ReactNativeTracingIntegration } from '../../../src/js/tracing/reactnativetracing'; import { reactNativeTracingIntegration } from '../../../src/js/tracing/reactnativetracing'; +import { startIdleNavigationSpan } from '../../../src/js/tracing/span'; import { NATIVE } from '../../../src/js/wrapper'; import type { TestClient } from '../../mocks/client'; import { setupTestClient } from '../../mocks/client'; -import type { MockedRoutingInstrumentation } from '../mockedrountinginstrumention'; -import { createMockedRoutingInstrumentation } from '../mockedrountinginstrumention'; type MockAppState = { setState: (state: AppStateStatus) => void; @@ -60,7 +59,6 @@ describe('User Interaction Tracing', () => { let client: TestClient; let tracing: ReactNativeTracingIntegration; let mockedUserInteractionId: { elementId: string | undefined; op: string }; - let mockedRoutingInstrumentation: MockedRoutingInstrumentation; beforeEach(() => { jest.useFakeTimers(); @@ -77,7 +75,6 @@ describe('User Interaction Tracing', () => { client = setupTestClient({ enableUserInteractionTracing: true, }); - mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); }); afterEach(() => { @@ -89,7 +86,6 @@ describe('User Interaction Tracing', () => { describe('disabled user interaction', () => { test('User interaction tracing is disabled by default', () => { client = setupTestClient({}); - mockedRoutingInstrumentation = createMockedRoutingInstrumentation(); startUserInteractionSpan(mockedUserInteractionId); expect(client.getOptions().enableUserInteractionTracing).toBeFalsy(); @@ -99,12 +95,10 @@ describe('User Interaction Tracing', () => { describe('enabled user interaction', () => { beforeEach(() => { - tracing = reactNativeTracingIntegration({ - routingInstrumentation: mockedRoutingInstrumentation, - }); + tracing = reactNativeTracingIntegration(); client.addIntegration(userInteractionIntegration()); client.addIntegration(tracing); - mockedRoutingInstrumentation.registeredOnConfirmRoute!('mockedRouteName'); + tracing.setCurrentRoute('mockedRouteName'); }); test('user interaction tracing is enabled and transaction is bound to scope', () => { @@ -272,8 +266,7 @@ describe('User Interaction Tracing', () => { startUserInteractionSpan(mockedUserInteractionId); const interactionTransaction = getActiveSpan(); jest.advanceTimersByTime(timeoutCloseToActualIdleTimeoutMs); - - const routingTransaction = mockedRoutingInstrumentation.registeredListener!({ + const routingTransaction = startIdleNavigationSpan({ name: 'newMockedRouteName', }); jest.runAllTimers(); diff --git a/test/tracing/mockedrountinginstrumention.ts b/test/tracing/mockedrountinginstrumention.ts deleted file mode 100644 index 53f0d68f7..000000000 --- a/test/tracing/mockedrountinginstrumention.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { RoutingInstrumentation } from '../../src/js'; -import type { OnConfirmRoute, TransactionCreator } from '../../src/js/tracing/routingInstrumentation'; -import type { BeforeNavigate } from '../../src/js/tracing/types'; - -export interface MockedRoutingInstrumentation extends RoutingInstrumentation { - registeredListener?: TransactionCreator; - registeredBeforeNavigate?: BeforeNavigate; - registeredOnConfirmRoute?: OnConfirmRoute; -} - -export const createMockedRoutingInstrumentation = (): MockedRoutingInstrumentation => { - const mock: MockedRoutingInstrumentation = { - name: 'TestRoutingInstrumentationInstance', - onRouteWillChange: jest.fn(), - registerRoutingInstrumentation: jest.fn( - (listener: TransactionCreator, beforeNavigate: BeforeNavigate, onConfirmRoute: OnConfirmRoute) => { - mock.registeredListener = listener; - mock.registeredBeforeNavigate = beforeNavigate; - mock.registeredOnConfirmRoute = onConfirmRoute; - }, - ), - }; - return mock; -}; diff --git a/test/tracing/reactnativenavigation.test.ts b/test/tracing/reactnativenavigation.test.ts index f07c7c550..5e1b9563d 100644 --- a/test/tracing/reactnativenavigation.test.ts +++ b/test/tracing/reactnativenavigation.test.ts @@ -16,7 +16,7 @@ import type { ComponentWillAppearEvent, EventsRegistry, } from '../../src/js/tracing/reactnativenavigation'; -import { ReactNativeNavigationInstrumentation } from '../../src/js/tracing/reactnativenavigation'; +import { reactNativeNavigationIntegration } from '../../src/js/tracing/reactnativenavigation'; import { SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_ID, SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_TYPE, @@ -356,20 +356,17 @@ describe('React Native Navigation Instrumentation', () => { } = {}, ) { createMockNavigation(); - const rNavigation = new ReactNativeNavigationInstrumentation( - { + const rNavigation = reactNativeNavigationIntegration({ + navigation: { events() { return mockEventsRegistry; }, }, - { - routeChangeTimeoutMs: 200, - enableTabsInstrumentation: setupOptions.enableTabsInstrumentation, - }, - ); + routeChangeTimeoutMs: 200, + enableTabsInstrumentation: setupOptions.enableTabsInstrumentation, + }); const rnTracing = reactNativeTracingIntegration({ - routingInstrumentation: rNavigation, beforeStartSpan: setupOptions.beforeStartSpan, }); @@ -377,7 +374,7 @@ describe('React Native Navigation Instrumentation', () => { tracesSampleRate: 1.0, enableStallTracking: false, enableNativeFramesTracking: false, - integrations: [rnTracing], + integrations: [rNavigation, rnTracing], enableAppStartTracking: false, }); client = new TestClient(options); diff --git a/test/tracing/reactnativetracing.test.ts b/test/tracing/reactnativetracing.test.ts index 9b84f14b3..80ee392ed 100644 --- a/test/tracing/reactnativetracing.test.ts +++ b/test/tracing/reactnativetracing.test.ts @@ -10,8 +10,6 @@ jest.mock('@sentry/utils', () => { import * as SentryBrowser from '@sentry/browser'; import type { Event } from '@sentry/types'; -import { RoutingInstrumentation } from '../../src/js/tracing/routingInstrumentation'; - jest.mock('../../src/js/wrapper', () => { return { NATIVE: { @@ -33,48 +31,16 @@ jest.mock('../../src/js/tracing/utils', () => { }; }); -type MockAppState = { - setState: (state: AppStateStatus) => void; - listener: (newState: AppStateStatus) => void; - removeSubscription: jest.Func; -}; -const mockedAppState: AppState & MockAppState = { - removeSubscription: jest.fn(), - listener: jest.fn(), - isAvailable: true, - currentState: 'active', - addEventListener: (_, listener) => { - mockedAppState.listener = listener; - return { - remove: mockedAppState.removeSubscription, - }; - }, - setState: (state: AppStateStatus) => { - mockedAppState.currentState = state; - mockedAppState.listener(state); - }, -}; -jest.mock('react-native/Libraries/AppState/AppState', () => mockedAppState); - -import { getActiveSpan, spanToJSON } from '@sentry/browser'; -import type { AppState, AppStateStatus } from 'react-native'; - import { reactNativeTracingIntegration } from '../../src/js/tracing/reactnativetracing'; -import { NATIVE } from '../../src/js/wrapper'; import type { TestClient } from '../mocks/client'; import { setupTestClient } from '../mocks/client'; describe('ReactNativeTracing', () => { + let client: TestClient; + beforeEach(() => { jest.useFakeTimers(); - NATIVE.enableNative = true; - mockedAppState.isAvailable = true; - mockedAppState.addEventListener = (_, listener) => { - mockedAppState.listener = listener; - return { - remove: mockedAppState.removeSubscription, - }; - }; + client = setupTestClient(); }); afterEach(() => { @@ -114,143 +80,42 @@ describe('ReactNativeTracing', () => { }); }); - describe('Tracing Instrumentation', () => { - let client: TestClient; - - beforeEach(() => { - client = setupTestClient(); - }); - - describe('With routing instrumentation', () => { - it('Cancels route transaction when app goes to background', async () => { - const routingInstrumentation = new RoutingInstrumentation(); - const integration = reactNativeTracingIntegration({ - routingInstrumentation, - }); - - integration.setup(client); - integration.afterAllSetup(client); - // wait for internal promises to resolve, fetch app start data from mocked native - await Promise.resolve(); - - const routeTransaction = routingInstrumentation.onRouteWillChange({ - name: 'test', - }); + describe('View Names event processor', () => { + it('Do not overwrite event app context', () => { + const integration = reactNativeTracingIntegration(); - mockedAppState.setState('background'); + const expectedRouteName = 'Route'; + const event: Event = { contexts: { app: { appKey: 'value' } } }; + const expectedEvent: Event = { contexts: { app: { appKey: 'value', view_names: [expectedRouteName] } } }; - jest.runAllTimers(); + integration.setCurrentRoute(expectedRouteName); + const processedEvent = integration.processEvent(event, {}, client); - expect(routeTransaction).toBeDefined(); - expect(spanToJSON(routeTransaction!).status).toBe('cancelled'); - expect(mockedAppState.removeSubscription).toBeCalledTimes(1); - }); - - it('Does not crash when AppState is not available', async () => { - mockedAppState.isAvailable = false; - mockedAppState.addEventListener = ((): void => { - return undefined; - }) as unknown as (typeof mockedAppState)['addEventListener']; // RN Web can return undefined - - const routingInstrumentation = new RoutingInstrumentation(); - setupTestClient({ - integrations: [ - reactNativeTracingIntegration({ - routingInstrumentation, - }), - ], - }); - - routingInstrumentation.onRouteWillChange({ - name: 'test', - }); - - await jest.advanceTimersByTimeAsync(500); - const transaction = getActiveSpan(); - - jest.runAllTimers(); - - expect(spanToJSON(transaction!).timestamp).toBeDefined(); - }); + expect(processedEvent).toEqual(expectedEvent); }); - }); - - describe('Routing Instrumentation', () => { - let client: TestClient; - - beforeEach(() => { - client = setupTestClient(); - }); - - describe('_onConfirmRoute', () => { - it('Sets app context', async () => { - const routing = new RoutingInstrumentation(); - const integration = reactNativeTracingIntegration({ - routingInstrumentation: routing, - }); - - client.addIntegration(integration); - - routing.onRouteWillChange({ name: 'First Route' }); - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - - routing.onRouteWillChange({ name: 'Second Route' }); - await jest.advanceTimersByTimeAsync(500); - await jest.runOnlyPendingTimersAsync(); - const transaction = client.event; - expect(transaction!.contexts!.app).toBeDefined(); - expect(transaction!.contexts!.app!['view_names']).toEqual(['Second Route']); - }); - - describe('View Names event processor', () => { - it('Do not overwrite event app context', () => { - const routing = new RoutingInstrumentation(); - const integration = reactNativeTracingIntegration({ - routingInstrumentation: routing, - }); - - const expectedRouteName = 'Route'; - const event: Event = { contexts: { app: { appKey: 'value' } } }; - const expectedEvent: Event = { contexts: { app: { appKey: 'value', view_names: [expectedRouteName] } } }; - - integration.state.currentRoute = expectedRouteName; - const processedEvent = integration.processEvent(event, {}, client); + it('Do not add view_names if context is undefined', () => { + const integration = reactNativeTracingIntegration(); - expect(processedEvent).toEqual(expectedEvent); - }); + const expectedRouteName = 'Route'; + const event: Event = { release: 'value' }; + const expectedEvent: Event = { release: 'value' }; - it('Do not add view_names if context is undefined', () => { - const routing = new RoutingInstrumentation(); - const integration = reactNativeTracingIntegration({ - routingInstrumentation: routing, - }); + integration.setCurrentRoute(expectedRouteName); + const processedEvent = integration.processEvent(event, {}, client); - const expectedRouteName = 'Route'; - const event: Event = { release: 'value' }; - const expectedEvent: Event = { release: 'value' }; - - integration.state.currentRoute = expectedRouteName; - const processedEvent = integration.processEvent(event, {}, client); - - expect(processedEvent).toEqual(expectedEvent); - }); + expect(processedEvent).toEqual(expectedEvent); + }); - it('ignore view_names if undefined', () => { - const routing = new RoutingInstrumentation(); - const integration = reactNativeTracingIntegration({ - routingInstrumentation: routing, - }); + it('ignore view_names if undefined', () => { + const integration = reactNativeTracingIntegration(); - const event: Event = { contexts: { app: { key: 'value ' } } }; - const expectedEvent: Event = { contexts: { app: { key: 'value ' } } }; + const event: Event = { contexts: { app: { key: 'value ' } } }; + const expectedEvent: Event = { contexts: { app: { key: 'value ' } } }; - const processedEvent = integration.processEvent(event, {}, client); + const processedEvent = integration.processEvent(event, {}, client); - expect(processedEvent).toEqual(expectedEvent); - }); - }); + expect(processedEvent).toEqual(expectedEvent); }); }); }); diff --git a/test/tracing/reactnavigation.stalltracking.test.ts b/test/tracing/reactnavigation.stalltracking.test.ts index b3548a98a..0fbc3b862 100644 --- a/test/tracing/reactnavigation.stalltracking.test.ts +++ b/test/tracing/reactnavigation.stalltracking.test.ts @@ -5,7 +5,7 @@ jest.mock('../../src/js/tracing/utils', () => ({ import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, startSpanManual } from '@sentry/core'; -import { reactNativeTracingIntegration, ReactNavigationInstrumentation } from '../../src/js'; +import { reactNativeTracingIntegration, reactNavigationIntegration } from '../../src/js'; import { stallTrackingIntegration } from '../../src/js/tracing/integrations/stalltracking'; import { isNearToNow } from '../../src/js/tracing/utils'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; @@ -26,12 +26,10 @@ describe('StallTracking with ReactNavigation', () => { getIsolationScope().clear(); getGlobalScope().clear(); - const rnavigation = new ReactNavigationInstrumentation(); + const rnavigation = reactNavigationIntegration(); mockNavigation = createMockNavigationAndAttachTo(rnavigation); - const rnTracing = reactNativeTracingIntegration({ - routingInstrumentation: rnavigation, - }); + const rnTracing = reactNativeTracingIntegration(); const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, diff --git a/test/tracing/reactnavigation.test.ts b/test/tracing/reactnavigation.test.ts index 9806aa891..a7e3ba7a0 100644 --- a/test/tracing/reactnavigation.test.ts +++ b/test/tracing/reactnavigation.test.ts @@ -1,12 +1,12 @@ /* eslint-disable deprecation/deprecation */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { getCurrentScope, getGlobalScope, getIsolationScope, SentrySpan, setCurrentClient } from '@sentry/core'; +import type { SentrySpan } from '@sentry/core'; +import { getActiveSpan, getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient } from '@sentry/core'; import type { Event, Measurements, StartSpanOptions } from '@sentry/types'; import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js'; -import { DEFAULT_NAVIGATION_SPAN_NAME } from '../../src/js/tracing/reactnativetracing'; import type { NavigationRoute } from '../../src/js/tracing/reactnavigation'; -import { ReactNavigationInstrumentation } from '../../src/js/tracing/reactnavigation'; +import { reactNavigationIntegration } from '../../src/js/tracing/reactnavigation'; import { SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_KEY, SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME, @@ -19,6 +19,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../../src/js/tracing/semanticAttributes'; +import { DEFAULT_NAVIGATION_SPAN_NAME } from '../../src/js/tracing/span'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; import { NATIVE } from '../mockWrapper'; @@ -85,7 +86,7 @@ describe('ReactNavigationInstrumentation', () => { }); describe('initial navigation span is created after all integrations are setup', () => { - let rnTracing: ReturnType; + let reactNavigation: ReturnType; beforeEach(() => { const startFrames = { @@ -100,14 +101,10 @@ describe('ReactNavigationInstrumentation', () => { }; NATIVE.fetchNativeFrames.mockResolvedValueOnce(startFrames).mockResolvedValueOnce(finishFrames); - const rNavigation = new ReactNavigationInstrumentation({ + reactNavigation = reactNavigationIntegration({ routeChangeTimeoutMs: 200, }); - mockNavigation = createMockNavigationAndAttachTo(rNavigation); - - rnTracing = reactNativeTracingIntegration({ - routingInstrumentation: rNavigation, - }); + mockNavigation = createMockNavigationAndAttachTo(reactNavigation); }); test('initial navigation span contains native frames when nativeFrames integration is after react native tracing', async () => { @@ -115,7 +112,7 @@ describe('ReactNavigationInstrumentation', () => { enableNativeFramesTracking: true, enableStallTracking: false, tracesSampleRate: 1.0, - integrations: [rnTracing, nativeFramesIntegration()], + integrations: [reactNavigation, nativeFramesIntegration()], enableAppStartTracking: false, }); client = new TestClient(options); @@ -134,7 +131,7 @@ describe('ReactNavigationInstrumentation', () => { enableNativeFramesTracking: true, enableStallTracking: false, tracesSampleRate: 1.0, - integrations: [nativeFramesIntegration(), rnTracing], + integrations: [nativeFramesIntegration(), reactNavigation], enableAppStartTracking: false, }); client = new TestClient(options); @@ -314,7 +311,7 @@ describe('ReactNavigationInstrumentation', () => { describe('navigation container registration', () => { test('registers navigation container object ref', () => { - const instrumentation = new ReactNavigationInstrumentation(); + const instrumentation = reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer({ current: mockNavigationContainer, @@ -327,7 +324,7 @@ describe('ReactNavigationInstrumentation', () => { }); test('registers navigation container direct ref', () => { - const instrumentation = new ReactNavigationInstrumentation(); + const instrumentation = reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer(mockNavigationContainer); @@ -340,7 +337,7 @@ describe('ReactNavigationInstrumentation', () => { test('does not register navigation container if there is an existing one', () => { RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true; - const instrumentation = new ReactNavigationInstrumentation(); + const instrumentation = reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer({ current: mockNavigationContainer, @@ -352,19 +349,14 @@ describe('ReactNavigationInstrumentation', () => { expect(mockNavigationContainer.addListener).not.toHaveBeenCalled(); }); - test('works if routing instrumentation registration is after navigation registration', async () => { - const instrumentation = new ReactNavigationInstrumentation(); + test('works if routing instrumentation setup is after navigation registration', async () => { + const instrumentation = reactNavigationIntegration(); const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer(mockNavigationContainer); - const mockTransaction = new SentrySpan(); - const tracingListener = jest.fn(() => mockTransaction); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => context, - () => {}, - ); + instrumentation.afterAllSetup(client); + const mockTransaction = getActiveSpan() as SentrySpan; await jest.runOnlyPendingTimersAsync(); @@ -374,17 +366,11 @@ describe('ReactNavigationInstrumentation', () => { describe('options', () => { test('waits until routeChangeTimeoutMs', () => { - const instrumentation = new ReactNavigationInstrumentation({ + const instrumentation = reactNavigationIntegration({ routeChangeTimeoutMs: 200, }); - const mockTransaction = new SentrySpan({ sampled: true, name: DEFAULT_NAVIGATION_SPAN_NAME }); - const tracingListener = jest.fn(() => mockTransaction); - instrumentation.registerRoutingInstrumentation( - tracingListener as any, - context => context, - () => {}, - ); + instrumentation.afterAllSetup(client); const mockNavigationContainerRef = { current: new MockNavigationContainer(), @@ -392,11 +378,12 @@ describe('ReactNavigationInstrumentation', () => { instrumentation.registerNavigationContainer(mockNavigationContainerRef as any); mockNavigationContainerRef.current.listeners['__unsafe_action__']({}); + const mockTransaction = getActiveSpan() as SentrySpan; jest.advanceTimersByTime(190); expect(mockTransaction['_sampled']).toBe(true); - expect(mockTransaction['_name']).toBe('Route'); + expect(mockTransaction['_name']).toBe(DEFAULT_NAVIGATION_SPAN_NAME); jest.advanceTimersByTime(20); @@ -409,13 +396,12 @@ describe('ReactNavigationInstrumentation', () => { beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions; } = {}, ) { - const rNavigation = new ReactNavigationInstrumentation({ + const rNavigation = reactNavigationIntegration({ routeChangeTimeoutMs: 200, }); mockNavigation = createMockNavigationAndAttachTo(rNavigation); const rnTracing = reactNativeTracingIntegration({ - routingInstrumentation: rNavigation, beforeStartSpan: setupOptions.beforeSpanStart, }); @@ -423,7 +409,7 @@ describe('ReactNavigationInstrumentation', () => { enableNativeFramesTracking: false, enableStallTracking: false, tracesSampleRate: 1.0, - integrations: [rnTracing], + integrations: [rNavigation, rnTracing], enableAppStartTracking: false, }); client = new TestClient(options); diff --git a/test/tracing/reactnavigation.ttid.test.tsx b/test/tracing/reactnavigation.ttid.test.tsx index 2f8005544..6e0855390 100644 --- a/test/tracing/reactnavigation.ttid.test.tsx +++ b/test/tracing/reactnavigation.ttid.test.tsx @@ -13,7 +13,6 @@ import React from "react"; import TestRenderer from 'react-test-renderer'; import * as Sentry from '../../src/js'; -import { ReactNavigationInstrumentation } from '../../src/js'; import { TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing'; import { _setAppStartEndTimestampMs } from '../../src/js/tracing/integrations/appStart'; import { isHermesEnabled, notWeb } from '../../src/js/utils/environment'; @@ -592,7 +591,10 @@ describe('React Navigation - TTID', () => { } function createTestedInstrumentation(options?: { enableTimeToInitialDisplay?: boolean }) { - const sut = new ReactNavigationInstrumentation(options); + const sut = Sentry.reactNavigationIntegration({ + ...options, + ignoreEmptyBackNavigationTransactions: true, // default true + }); return sut; } @@ -602,7 +604,7 @@ describe('React Navigation - TTID', () => { } }); -function initSentry(sut: ReactNavigationInstrumentation): { +function initSentry(sut: ReturnType): { transportSendMock: jest.Mock, Parameters>; } { RN_GLOBAL_OBJ.__sentry_rn_v5_registered = false; @@ -612,10 +614,8 @@ function initSentry(sut: ReactNavigationInstrumentation): { enableTracing: true, enableStallTracking: false, integrations: [ - Sentry.reactNativeTracingIntegration({ - routingInstrumentation: sut, - ignoreEmptyBackNavigationTransactions: true, // default true - }), + sut, + Sentry.reactNativeTracingIntegration(), ], transport: () => ({ send: transportSendMock.mockResolvedValue({}), diff --git a/test/tracing/reactnavigationutils.ts b/test/tracing/reactnavigationutils.ts index 5bdfa5f19..3aba609d1 100644 --- a/test/tracing/reactnavigationutils.ts +++ b/test/tracing/reactnavigationutils.ts @@ -1,6 +1,6 @@ -import type { NavigationRoute, ReactNavigationInstrumentation } from '../../src/js/tracing/reactnavigation'; +import type { NavigationRoute, reactNavigationIntegration } from '../../src/js/tracing/reactnavigation'; -export function createMockNavigationAndAttachTo(sut: ReactNavigationInstrumentation) { +export function createMockNavigationAndAttachTo(sut: ReturnType) { const mockedNavigationContained = mockNavigationContainer(); const mockedNavigation = { emitCancelledNavigation: () => {