UNPKG

@sentry/react-native

Version:
361 lines 15 kB
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