@sentry/react-native
Version:
Official Sentry SDK for react-native
361 lines • 15 kB
JavaScript
import { addBreadcrumb, debug, dropUndefinedKeys, getClient, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import { createIntegration } from './integrations/factory';
import { DEFAULT_RAGE_TAP_THRESHOLD, DEFAULT_RAGE_TAP_TIME_WINDOW, RageTapDetector } from './ragetap';
import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay';
import { startUserInteractionSpan } from './tracing/integrations/userInteraction';
import { UI_ACTION_TOUCH } from './tracing/ops';
import { SPAN_ORIGIN_AUTO_INTERACTION } from './tracing/origin';
const touchEventStyles = StyleSheet.create({
wrapperView: {
flex: 1,
},
});
const DEFAULT_BREADCRUMB_CATEGORY = 'touch';
const DEFAULT_BREADCRUMB_TYPE = 'user';
const DEFAULT_MAX_COMPONENT_TREE_SIZE = 20;
const SENTRY_LABEL_PROP_KEY = 'sentry-label';
const SENTRY_SPAN_ATTRIBUTES_PROP_KEY = 'sentry-span-attributes';
const SENTRY_COMPONENT_PROP_KEY = 'data-sentry-component';
const SENTRY_ELEMENT_PROP_KEY = 'data-sentry-element';
const SENTRY_FILE_PROP_KEY = 'data-sentry-source-file';
const ACCESSIBILITY_LABEL_PROP_KEY = 'accessibilityLabel';
const ARIA_LABEL_PROP_KEY = 'aria-label';
const TEST_ID_PROP_KEY = 'testID';
const MASK_COMPONENT_NAME = 'RNSentryReplayMask';
const MAX_TEXT_LENGTH = 64;
const MAX_TEXT_EXTRACTION_DEPTH = 3;
const MAX_SIBLINGS_TO_VISIT = 5;
/**
* Boundary to log breadcrumbs for interaction events.
*/
class TouchEventBoundary extends React.Component {
constructor(props) {
super(props);
this.name = 'TouchEventBoundary';
this._rageTapDetector = new RageTapDetector({
enabled: props.enableRageTapDetection,
threshold: props.rageTapThreshold,
timeWindow: props.rageTapTimeWindow,
});
}
/**
* Registers the TouchEventBoundary as a Sentry Integration.
*/
componentDidMount() {
var _a;
const client = getClient();
(_a = client === null || client === void 0 ? void 0 : client.addIntegration) === null || _a === void 0 ? void 0 : _a.call(client, createIntegration(this.name));
}
/**
* Sync rage tap options when props change.
*/
componentDidUpdate() {
this._rageTapDetector.updateOptions({
enabled: this.props.enableRageTapDetection,
threshold: this.props.rageTapThreshold,
timeWindow: this.props.rageTapTimeWindow,
});
}
/**
*
*/
render() {
return (React.createElement(View, { style: touchEventStyles.wrapperView,
// oxlint-disable-next-line typescript-eslint(no-explicit-any)
onTouchStart: this._onTouchStart.bind(this) }, this.props.children));
}
/**
* Logs the touch event given the component tree names and a label.
*/
_logTouchEvent(touchPath, label) {
const level = 'info';
const root = touchPath[0];
if (!root) {
debug.warn('[TouchEvents] No root component found in touch path.');
return;
}
const detail = label ? label : `${root.name}${root.file ? ` (${root.file})` : ''}`;
const crumb = {
category: this.props.breadcrumbCategory,
data: { path: touchPath },
level: level,
message: `Touch event within element: ${detail}`,
type: this.props.breadcrumbType,
};
addBreadcrumb(crumb);
debug.log(`[TouchEvents] ${crumb.message}`);
}
/**
* Checks if the name is supposed to be ignored.
*/
_isNameIgnored(name) {
let ignoreNames = this.props.ignoreNames || [];
if (this.props.ignoredDisplayNames) {
// This is to make it compatible with prior version.
ignoreNames = [...ignoreNames, ...this.props.ignoredDisplayNames];
}
return ignoreNames.some((ignoreName) => (typeof ignoreName === 'string' && name === ignoreName) ||
(ignoreName instanceof RegExp && name.match(ignoreName)));
}
// Originally was going to clean the names of any HOCs as well but decided that it might hinder debugging effectively. Will leave here in case
// private readonly _cleanName = (name: string): string =>
// name.replace(/.*\(/g, "").replace(/\)/g, "");
/**
* Traverses through the component tree when a touch happens and logs it.
* @param e
*/
_onTouchStart(e) {
var _a, _b, _c;
if (!e._targetInst) {
return;
}
let currentInst = e._targetInst;
const touchPath = [];
const maskAllText = this._isMaskAllTextEnabled();
const isInsideMask = !maskAllText && hasAncestorMask(e._targetInst);
const shouldReadSentryLabel = !maskAllText && !isInsideMask;
const shouldExtractText = this._shouldExtractText() && !isInsideMask;
while (currentInst &&
// maxComponentTreeSize will always be defined as we have a defaultProps. But ts needs a check so this is here.
this.props.maxComponentTreeSize &&
touchPath.length < this.props.maxComponentTreeSize) {
if (
// If the loop gets to the boundary itself, break.
((_a = currentInst.elementType) === null || _a === void 0 ? void 0 : _a.displayName) === TouchEventBoundary.displayName) {
break;
}
const info = getTouchedComponentInfo(currentInst, this.props.labelName, shouldExtractText, shouldReadSentryLabel);
this._pushIfNotIgnored(touchPath, info);
currentInst = currentInst.return;
}
const label = (_b = touchPath.find(info => info.label)) === null || _b === void 0 ? void 0 : _b.label;
if (touchPath.length > 0) {
this._logTouchEvent(touchPath, label);
this._rageTapDetector.check(touchPath, label);
}
const span = startUserInteractionSpan({
elementId: label,
op: UI_ACTION_TOUCH,
});
if (span) {
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_INTERACTION);
// Apply custom attributes from sentry-span-attributes prop
// Traverse the component tree to find custom attributes
let instForAttributes = e._targetInst;
let customAttributes;
while (instForAttributes) {
if (((_c = instForAttributes.elementType) === null || _c === void 0 ? void 0 : _c.displayName) === TouchEventBoundary.displayName) {
break;
}
customAttributes = getSpanAttributes(instForAttributes);
if (customAttributes && Object.keys(customAttributes).length > 0) {
break;
}
instForAttributes = instForAttributes.return;
}
if (customAttributes && Object.keys(customAttributes).length > 0) {
span.setAttributes(customAttributes);
}
}
}
_isMaskAllTextEnabled() {
const client = getClient();
if (!client) {
return false;
}
const replayIntegration = client.getIntegrationByName(MOBILE_REPLAY_INTEGRATION_NAME);
if (!replayIntegration) {
return false;
}
if (!('options' in replayIntegration)) {
return true;
}
const options = replayIntegration.options;
return options.maskAllText !== false;
}
_shouldExtractText() {
if (!this.props.extractTextFromChildren) {
return false;
}
return !this._isMaskAllTextEnabled();
}
/**
* Pushes the name to the componentTreeNames array if it is not ignored.
*/
_pushIfNotIgnored(touchPath, value) {
if (!value) {
return false;
}
if (!value.name && !value.label) {
return false;
}
if (value.name && this._isNameIgnored(value.name)) {
return false;
}
if (value.label && this._isNameIgnored(value.label)) {
return false;
}
// Deduplicate same subsequent items.
if (touchPath.length > 0 && JSON.stringify(touchPath[touchPath.length - 1]) === JSON.stringify(value)) {
return false;
}
touchPath.push(value);
return true;
}
}
TouchEventBoundary.displayName = '__Sentry.TouchEventBoundary';
TouchEventBoundary.defaultProps = {
breadcrumbCategory: DEFAULT_BREADCRUMB_CATEGORY,
breadcrumbType: DEFAULT_BREADCRUMB_TYPE,
ignoreNames: [],
maxComponentTreeSize: DEFAULT_MAX_COMPONENT_TREE_SIZE,
enableRageTapDetection: true,
rageTapThreshold: DEFAULT_RAGE_TAP_THRESHOLD,
rageTapTimeWindow: DEFAULT_RAGE_TAP_TIME_WINDOW,
extractTextFromChildren: true,
};
function getTouchedComponentInfo(currentInst, labelKey, shouldExtractText, shouldReadSentryLabel) {
var _a;
const displayName = (_a = currentInst.elementType) === null || _a === void 0 ? void 0 : _a.displayName;
const props = currentInst.memoizedProps;
if (!props || typeof props === 'string') {
if (displayName) {
return {
name: displayName,
};
}
return undefined;
}
const label = getLabelValue(props, labelKey, shouldReadSentryLabel) ||
(shouldExtractText ? extractTextFromFiber(currentInst) : undefined);
return dropUndefinedKeys({
// provided by @sentry/babel-plugin-component-annotate
name: getComponentName(props) || displayName,
element: getElementName(props),
file: getFileName(props),
label,
});
}
function getComponentName(props) {
return ((typeof props[SENTRY_COMPONENT_PROP_KEY] === 'string' &&
props[SENTRY_COMPONENT_PROP_KEY].length > 0 &&
props[SENTRY_COMPONENT_PROP_KEY] !== 'unknown' &&
props[SENTRY_COMPONENT_PROP_KEY]) ||
undefined);
}
function getElementName(props) {
return ((typeof props[SENTRY_ELEMENT_PROP_KEY] === 'string' &&
props[SENTRY_ELEMENT_PROP_KEY].length > 0 &&
props[SENTRY_ELEMENT_PROP_KEY] !== 'unknown' &&
props[SENTRY_ELEMENT_PROP_KEY]) ||
undefined);
}
function getFileName(props) {
return ((typeof props[SENTRY_FILE_PROP_KEY] === 'string' &&
props[SENTRY_FILE_PROP_KEY].length > 0 &&
props[SENTRY_FILE_PROP_KEY] !== 'unknown' &&
props[SENTRY_FILE_PROP_KEY]) ||
undefined);
}
function getLabelValue(props, labelKey, readSentryLabel = true) {
if (readSentryLabel && typeof props[SENTRY_LABEL_PROP_KEY] === 'string' && props[SENTRY_LABEL_PROP_KEY].length > 0) {
return props[SENTRY_LABEL_PROP_KEY];
}
// For some reason type narrowing doesn't work as expected with indexing when checking it all in one go in
// the "check-label" if sentence, so we have to assign it to a variable here first
// oxlint-disable-next-line typescript-eslint(no-unnecessary-type-assertion)
if (typeof labelKey === 'string' && typeof props[labelKey] == 'string' && props[labelKey].length > 0) {
// oxlint-disable-next-line typescript-eslint(no-unnecessary-type-assertion)
return props[labelKey];
}
if (typeof props[ACCESSIBILITY_LABEL_PROP_KEY] === 'string' && props[ACCESSIBILITY_LABEL_PROP_KEY].length > 0) {
return props[ACCESSIBILITY_LABEL_PROP_KEY];
}
if (typeof props[ARIA_LABEL_PROP_KEY] === 'string' && props[ARIA_LABEL_PROP_KEY].length > 0) {
return props[ARIA_LABEL_PROP_KEY];
}
if (typeof props[TEST_ID_PROP_KEY] === 'string' && props[TEST_ID_PROP_KEY].length > 0) {
return props[TEST_ID_PROP_KEY];
}
return undefined;
}
function getSpanAttributes(currentInst) {
if (!currentInst.memoizedProps || typeof currentInst.memoizedProps === 'string') {
return undefined;
}
const props = currentInst.memoizedProps;
const attributes = props[SENTRY_SPAN_ATTRIBUTES_PROP_KEY];
// Validate that it's an object (not null, not array)
if (typeof attributes === 'object' && attributes !== null && !Array.isArray(attributes)) {
return attributes;
}
return undefined;
}
function hasAncestorMask(inst) {
var _a, _b;
let current = inst.return;
while (current) {
if (((_a = current.elementType) === null || _a === void 0 ? void 0 : _a.name) === MASK_COMPONENT_NAME || ((_b = current.elementType) === null || _b === void 0 ? void 0 : _b.displayName) === MASK_COMPONENT_NAME) {
return true;
}
current = current.return;
}
return false;
}
function extractTextFromFiber(inst) {
const parts = [];
collectTextFromFiber(inst.child, parts, 0);
if (parts.length === 0) {
return undefined;
}
const text = parts.join(' ').trim();
if (text.length === 0) {
return undefined;
}
if (text.length > MAX_TEXT_LENGTH) {
return `${text.slice(0, MAX_TEXT_LENGTH)}...`;
}
return text;
}
function collectTextFromFiber(inst, parts, depth, siblingIndex = 0) {
var _a, _b;
if (!inst || depth > MAX_TEXT_EXTRACTION_DEPTH || siblingIndex >= MAX_SIBLINGS_TO_VISIT) {
return;
}
if (((_a = inst.elementType) === null || _a === void 0 ? void 0 : _a.name) === MASK_COMPONENT_NAME || ((_b = inst.elementType) === null || _b === void 0 ? void 0 : _b.displayName) === MASK_COMPONENT_NAME) {
// Skip masked node's children but still visit its siblings
collectTextFromFiber(inst.sibling, parts, depth, siblingIndex + 1);
return;
}
const props = inst.memoizedProps;
if (typeof props === 'string') {
// Raw text fiber (HostText) — no children to recurse into
parts.push(props);
}
else if (typeof (props === null || props === void 0 ? void 0 : props.children) === 'string') {
// Component with string children — skip child recursion to avoid
// duplicating text from the HostText child fiber
parts.push(props.children);
}
else {
collectTextFromFiber(inst.child, parts, depth + 1, 0);
}
collectTextFromFiber(inst.sibling, parts, depth, siblingIndex + 1);
}
/**
* Convenience Higher-Order-Component for TouchEventBoundary
* @param WrappedComponent any React Component
* @param boundaryProps TouchEventBoundaryProps
*/
const withTouchEventBoundary = (
// oxlint-disable-next-line typescript-eslint(no-explicit-any)
InnerComponent, boundaryProps) => {
const WrappedComponent = props => (React.createElement(TouchEventBoundary, Object.assign({}, (boundaryProps !== null && boundaryProps !== void 0 ? boundaryProps : {})),
React.createElement(InnerComponent, Object.assign({}, props))));
WrappedComponent.displayName = 'WithTouchEventBoundary';
return WrappedComponent;
};
export { TouchEventBoundary, withTouchEventBoundary };
//# sourceMappingURL=touchevents.js.map