diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6ed6a76d..5373d3922a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Extract text content from children of touched components as a label fallback for touch breadcrumbs ([#6106](https://github.com/getsentry/sentry-react-native/pull/6106)) + ### Dependencies - Bump JavaScript SDK from v10.51.0 to v10.52.0 ([#6108](https://github.com/getsentry/sentry-react-native/pull/6108)) diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 466c93d761..2a474fcd72 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -1,3 +1,4 @@ +/* oxlint-disable eslint(max-lines) */ import type { SeverityLevel, SpanAttributeValue } from '@sentry/core'; import type { GestureResponderEvent } from 'react-native'; @@ -9,6 +10,7 @@ import type { TouchedComponentInfo } from './ragetap'; 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'; @@ -70,6 +72,15 @@ export type TouchEventBoundaryProps = { * @default 1000 */ rageTapTimeWindow?: number; + /** + * Extract text content from children of touched components as a label fallback. + * Automatically disabled when Session Replay's `maskAllText` is enabled (the default) + * to avoid leaking masked content via breadcrumbs. Set `maskAllText: false` in your + * `mobileReplayIntegration` config to enable text extraction. + * Per-view `Sentry.Mask` boundaries are also respected. + * Set to `false` to opt out of text extraction entirely. + */ + extractTextFromChildren?: boolean; }; const touchEventStyles = StyleSheet.create({ @@ -91,13 +102,21 @@ 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; + interface ElementInstance { elementType?: { displayName?: string; name?: string; }; - memoizedProps?: Record; + // Raw text fiber nodes store a string instead of an object + memoizedProps?: Record | string; return?: ElementInstance; + child?: ElementInstance; + sibling?: ElementInstance; } interface PrivateGestureResponderEvent extends GestureResponderEvent { @@ -117,6 +136,7 @@ class TouchEventBoundary extends React.Component { enableRageTapDetection: true, rageTapThreshold: DEFAULT_RAGE_TAP_THRESHOLD, rageTapTimeWindow: DEFAULT_RAGE_TAP_TIME_WINDOW, + extractTextFromChildren: true, }; public readonly name: string = 'TouchEventBoundary'; @@ -223,6 +243,7 @@ class TouchEventBoundary extends React.Component { let currentInst: ElementInstance | undefined = e._targetInst; const touchPath: TouchedComponentInfo[] = []; + const shouldExtractText = this._shouldExtractText(); while ( currentInst && @@ -237,7 +258,7 @@ class TouchEventBoundary extends React.Component { break; } - const info = getTouchedComponentInfo(currentInst, this.props.labelName); + const info = getTouchedComponentInfo(currentInst, this.props.labelName, shouldExtractText); this._pushIfNotIgnored(touchPath, info); currentInst = currentInst.return; @@ -280,6 +301,24 @@ class TouchEventBoundary extends React.Component { } } + private _shouldExtractText(): boolean { + if (!this.props.extractTextFromChildren) { + return false; + } + const client = getClient(); + if (!client) { + return true; + } + const replayIntegration = client.getIntegrationByName(MOBILE_REPLAY_INTEGRATION_NAME); + if (replayIntegration && 'options' in replayIntegration) { + const options = replayIntegration.options as { maskAllText?: boolean }; + if (options.maskAllText !== false) { + return false; + } + } + return true; + } + /** * Pushes the name to the componentTreeNames array if it is not ignored. */ @@ -311,12 +350,12 @@ class TouchEventBoundary extends React.Component { function getTouchedComponentInfo( currentInst: ElementInstance, labelKey: string | undefined, + shouldExtractText: boolean, ): TouchedComponentInfo | undefined { const displayName = currentInst.elementType?.displayName; const props = currentInst.memoizedProps; - if (!props) { - // Early return if no props are available, as we can't extract any useful information + if (!props || typeof props === 'string') { if (displayName) { return { name: displayName, @@ -325,14 +364,15 @@ function getTouchedComponentInfo( return undefined; } + const label = getLabelValue(props, labelKey) || (shouldExtractText ? extractTextFromFiber(currentInst) : undefined); + return dropUndefinedKeys({ // provided by @sentry/babel-plugin-component-annotate name: getComponentName(props) || displayName, element: getElementName(props), file: getFileName(props), - // `sentry-label` or user defined label key - label: getLabelValue(props, labelKey), + label, }); } @@ -395,7 +435,7 @@ function getLabelValue(props: Record, labelKey: string | undefi } function getSpanAttributes(currentInst: ElementInstance): Record | undefined { - if (!currentInst.memoizedProps) { + if (!currentInst.memoizedProps || typeof currentInst.memoizedProps === 'string') { return undefined; } @@ -410,6 +450,53 @@ function getSpanAttributes(currentInst: ElementInstance): Record MAX_TEXT_LENGTH) { + return `${text.slice(0, MAX_TEXT_LENGTH)}...`; + } + return text; +} + +function collectTextFromFiber( + inst: ElementInstance | undefined, + parts: string[], + depth: number, + siblingIndex: number = 0, +): void { + if (!inst || depth > MAX_TEXT_EXTRACTION_DEPTH || siblingIndex >= MAX_SIBLINGS_TO_VISIT) { + return; + } + + if (inst.elementType?.name === MASK_COMPONENT_NAME || inst.elementType?.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?.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 diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index 3bed16b5ed..03af780bff 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -903,4 +903,427 @@ describe('TouchEventBoundary._onTouchStart', () => { expect(() => boundary._onTouchStart(event)).not.toThrow(); }); }); + + describe('text extraction from children', () => { + it('extracts text from child fiber nodes when no label is set', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Save workout' }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Save workout', + data: { + path: [{ name: 'TouchableOpacity', label: 'Save workout' }], + }, + }), + ); + }); + + it('does not duplicate text when props.children and HostText child both exist', () => { + // In real React Native fiber trees, Hello has both: + // - Text fiber: memoizedProps = { children: 'Hello' } + // - HostText child fiber: memoizedProps = 'Hello' (raw string) + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Save workout' }, + child: { + // HostText fiber — raw string props + memoizedProps: 'Save workout', + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Save workout', + data: { + path: [{ name: 'TouchableOpacity', label: 'Save workout' }], + }, + }), + ); + }); + + it('extracts text from nested fiber children', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'Pressable' }, + memoizedProps: {}, + child: { + elementType: { name: 'View' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Continue' }, + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Continue', + }), + ); + }); + + it('collects text from sibling fiber nodes', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Add' }, + sibling: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'to cart' }, + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Add to cart', + }), + ); + }); + + it('truncates long text at 64 characters', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const longText = 'A'.repeat(100); + const event = { + _targetInst: { + elementType: { displayName: 'Button' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: longText }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: `Touch event within element: ${'A'.repeat(64)}...`, + }), + ); + }); + + it('does not extract text when maskAllText is enabled', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue({ + name: 'MobileReplay', + options: { maskAllText: true }, + } as any); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Save workout' }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: TouchableOpacity', + data: { path: [{ name: 'TouchableOpacity' }] }, + }), + ); + }); + + it('extracts text when maskAllText is explicitly false', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue({ + name: 'MobileReplay', + options: { maskAllText: false }, + } as any); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Save workout' }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Save workout', + }), + ); + }); + + it('does not extract text when MobileReplay integration has no options property', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue({ + name: 'MobileReplay', + setupOnce: jest.fn(), + } as any); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Save workout' }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Save workout', + }), + ); + }); + + it('does not extract text when maskAllText is not set (defaults to masked)', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue({ + name: 'MobileReplay', + options: {}, + } as any); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Save workout' }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: TouchableOpacity', + data: { path: [{ name: 'TouchableOpacity' }] }, + }), + ); + }); + + it('stops at Sentry.Mask boundary', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'RNSentryReplayMask' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Secret text' }, + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: TouchableOpacity', + data: { path: [{ name: 'TouchableOpacity' }] }, + }), + ); + }); + + it('collects sibling text after Sentry.Mask boundary', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'RNSentryReplayMask' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Secret text' }, + }, + sibling: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Visible text' }, + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Visible text', + data: { path: [{ name: 'TouchableOpacity', label: 'Visible text' }] }, + }), + ); + }); + + it('sentry-label takes priority over extracted text', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: { 'sentry-label': 'my-button' }, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Save workout' }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: my-button', + }), + ); + }); + + it('does not extract text when extractTextFromChildren prop is false', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary({ + ...defaultProps, + extractTextFromChildren: false, + }); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: { children: 'Save workout' }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: TouchableOpacity', + data: { path: [{ name: 'TouchableOpacity' }] }, + }), + ); + }); + + it('handles string memoizedProps (raw text fiber nodes)', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + jest.spyOn(client, 'getIntegrationByName').mockReturnValue(undefined); + + const event = { + _targetInst: { + elementType: { displayName: 'TouchableOpacity' }, + memoizedProps: {}, + child: { + elementType: { name: 'Text' }, + memoizedProps: {}, + child: { + memoizedProps: 'Hello world', + }, + }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Touch event within element: Hello world', + }), + ); + }); + }); });