@sentry/react-native
Version:
Official Sentry SDK for react-native
247 lines (246 loc) • 13.1 kB
JavaScript
import { addBreadcrumb, getActiveSpan, getClient, isPlainObject, logger, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_OK, spanToJSON, startInactiveSpan, timestampInSeconds, } from '@sentry/core';
import { createSentryFallbackEventEmitter } from '../utils/sentryeventemitterfallback';
import { isSentrySpan } from '../utils/span';
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
import { NATIVE } from '../wrapper';
import { ignoreEmptyBackNavigation } from './onSpanEndUtils';
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin';
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 { setSpanDurationAsMeasurementOnSpan } from './utils';
export const INTEGRATION_NAME = 'ReactNavigation';
const NAVIGATION_HISTORY_MAX_SIZE = 200;
/**
* Instrumentation for React-Navigation V5 and above. See docs or sample app for usage.
*
* How this works:
* - `_onDispatch` is called every time a dispatch happens and sets an IdleTransaction on the scope without any route context.
* - `_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 const reactNavigationIntegration = ({ routeChangeTimeoutMs = 1000, enableTimeToInitialDisplay = false, ignoreEmptyBackNavigationTransactions = true, } = {}) => {
let navigationContainer;
let newScreenFrameEventEmitter;
let tracing;
let idleSpanOptions = defaultIdleOptions;
let latestRoute;
let latestNavigationSpan;
let navigationProcessingSpan;
let initialStateHandled = false;
let stateChangeTimeout;
let recentRouteKeys = [];
if (enableTimeToInitialDisplay) {
newScreenFrameEventEmitter = createSentryFallbackEventEmitter();
newScreenFrameEventEmitter.initAsync();
NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason) => {
logger.error(`${INTEGRATION_NAME} Failed to initialize native new frame tracking: ${reason}`);
});
}
/**
* Set the initial state and start initial navigation span for the current screen.
*/
const afterAllSetup = (client) => {
tracing = getReactNativeTracingIntegration(client);
if (tracing) {
idleSpanOptions = {
finalTimeout: tracing.options.finalTimeoutMs,
idleTimeout: tracing.options.idleTimeoutMs,
};
}
if (initialStateHandled) {
// We create an initial state here to ensure a transaction gets created before the first route mounts.
return undefined;
}
startIdleNavigationSpan();
if (!navigationContainer) {
// This is expected as navigation container is registered after the root component is mounted.
return undefined;
}
// Navigation container already registered, just populate with route state
updateLatestNavigationSpanWithCurrentRoute();
initialStateHandled = true;
};
const registerNavigationContainer = (navigationContainerRef) => {
/* 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) {
logger.log(`${INTEGRATION_NAME} Instrumentation already exists, but register has been called again, doing nothing.`);
return undefined;
}
if (isPlainObject(navigationContainerRef) && 'current' in navigationContainerRef) {
navigationContainer = navigationContainerRef.current;
}
else {
navigationContainer = navigationContainerRef;
}
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 updateLatestNavigationSpanWithCurrentRoute
*/
const startIdleNavigationSpan = () => {
if (latestNavigationSpan) {
logger.log(`${INTEGRATION_NAME} A transaction was detected that turned out to be a noop, discarding.`);
_discardLatestTransaction();
clearStateChangeTimeout();
}
latestNavigationSpan = startGenericIdleNavigationSpan(tracing && tracing.options.beforeStartSpan
? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions())
: getDefaultIdleNavigationSpanOptions(), idleSpanOptions);
latestNavigationSpan === null || latestNavigationSpan === void 0 ? void 0 : latestNavigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION);
if (ignoreEmptyBackNavigationTransactions) {
ignoreEmptyBackNavigation(getClient(), latestNavigationSpan);
}
if (enableTimeToInitialDisplay) {
navigationProcessingSpan = startInactiveSpan({
op: 'navigation.processing',
name: 'Navigation dispatch to navigation cancelled or screen mounted',
startTime: latestNavigationSpan && spanToJSON(latestNavigationSpan).start_timestamp,
});
navigationProcessingSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION);
}
stateChangeTimeout = setTimeout(_discardLatestTransaction, routeChangeTimeoutMs);
};
/**
* To be called AFTER the state has been changed to populate the transaction with the current route.
*/
const updateLatestNavigationSpanWithCurrentRoute = () => {
const stateChangedTimestamp = timestampInSeconds();
const previousRoute = latestRoute;
if (!navigationContainer) {
logger.warn(`${INTEGRATION_NAME} Missing navigation container ref. Route transactions will not be sent.`);
return undefined;
}
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;
}
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 routeHasBeenSeen = recentRouteKeys.includes(route.key);
const latestTtidSpan = !routeHasBeenSeen &&
enableTimeToInitialDisplay &&
startTimeToInitialDisplaySpan({
name: `${route.name} initial display`,
isAutoInstrumented: true,
});
const navigationSpanWithTtid = latestNavigationSpan;
if (!routeHasBeenSeen && latestTtidSpan) {
newScreenFrameEventEmitter === null || newScreenFrameEventEmitter === void 0 ? void 0 : newScreenFrameEventEmitter.onceNewFrame(({ newFrameTimestampInSeconds }) => {
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, navigationSpanWithTtid);
});
}
navigationProcessingSpan === null || navigationProcessingSpan === void 0 ? void 0 : navigationProcessingSpan.updateName(`Navigation dispatch to screen ${route.name} mounted`);
navigationProcessingSpan === null || navigationProcessingSpan === void 0 ? void 0 : navigationProcessingSpan.setStatus({ code: SPAN_STATUS_OK });
navigationProcessingSpan === null || navigationProcessingSpan === void 0 ? void 0 : 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 === null || previousRoute === void 0 ? void 0 : previousRoute.name,
'previous_route.key': previousRoute === null || previousRoute === void 0 ? void 0 : 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 === null || previousRoute === void 0 ? void 0 : previousRoute.name,
to: route.name,
},
});
tracing === null || tracing === void 0 ? void 0 : tracing.setCurrentRoute(route.name);
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 */
const pushRecentRouteKey = (key) => {
recentRouteKeys.push(key);
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. */
const _discardLatestTransaction = () => {
if (latestNavigationSpan) {
if (isSentrySpan(latestNavigationSpan)) {
latestNavigationSpan['_sampled'] = false;
}
// TODO: What if it's not SentrySpan?
latestNavigationSpan.end();
latestNavigationSpan = undefined;
}
if (navigationProcessingSpan) {
navigationProcessingSpan = undefined;
}
};
const clearStateChangeTimeout = () => {
if (typeof stateChangeTimeout !== 'undefined') {
clearTimeout(stateChangeTimeout);
stateChangeTimeout = undefined;
}
};
return {
name: INTEGRATION_NAME,
afterAllSetup,
registerNavigationContainer,
};
};
//# sourceMappingURL=reactnavigation.js.map