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: () => {