Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
### Features

- Use `accessibilityLabel`, `aria-label`, and `testID` as fallback labels for touch breadcrumbs when `sentry-label` is not set ([#6103](https://github.com/getsentry/sentry-react-native/pull/6103))
- 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))

### Fixes

Expand Down
97 changes: 90 additions & 7 deletions packages/core/src/js/touchevents.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* oxlint-disable eslint(max-lines) */
import type { SeverityLevel, SpanAttributeValue } from '@sentry/core';
import type { GestureResponderEvent } from 'react-native';

Expand All @@ -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';
Expand Down Expand Up @@ -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({
Expand All @@ -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<string, unknown>;
// Raw text fiber nodes store a string instead of an object
memoizedProps?: Record<string, unknown> | string;
return?: ElementInstance;
child?: ElementInstance;
sibling?: ElementInstance;
}

interface PrivateGestureResponderEvent extends GestureResponderEvent {
Expand All @@ -117,6 +136,7 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
enableRageTapDetection: true,
rageTapThreshold: DEFAULT_RAGE_TAP_THRESHOLD,
rageTapTimeWindow: DEFAULT_RAGE_TAP_TIME_WINDOW,
extractTextFromChildren: true,
};

public readonly name: string = 'TouchEventBoundary';
Expand Down Expand Up @@ -223,6 +243,7 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {

let currentInst: ElementInstance | undefined = e._targetInst;
const touchPath: TouchedComponentInfo[] = [];
const shouldExtractText = this._shouldExtractText();

while (
currentInst &&
Expand All @@ -237,7 +258,7 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
break;
}

const info = getTouchedComponentInfo(currentInst, this.props.labelName);
const info = getTouchedComponentInfo(currentInst, this.props.labelName, shouldExtractText);
this._pushIfNotIgnored(touchPath, info);

currentInst = currentInst.return;
Expand Down Expand Up @@ -280,6 +301,24 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
}
}

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.
*/
Expand Down Expand Up @@ -311,12 +350,12 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
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,
Expand All @@ -325,14 +364,15 @@ function getTouchedComponentInfo(
return undefined;
}

const label = getLabelValue(props, labelKey) || (shouldExtractText ? extractTextFromFiber(currentInst) : undefined);

return dropUndefinedKeys<TouchedComponentInfo>({
// 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,
});
}

Expand Down Expand Up @@ -395,7 +435,7 @@ function getLabelValue(props: Record<string, unknown>, labelKey: string | undefi
}

function getSpanAttributes(currentInst: ElementInstance): Record<string, SpanAttributeValue> | undefined {
if (!currentInst.memoizedProps) {
if (!currentInst.memoizedProps || typeof currentInst.memoizedProps === 'string') {
return undefined;
}

Expand All @@ -410,6 +450,49 @@ function getSpanAttributes(currentInst: ElementInstance): Record<string, SpanAtt
return undefined;
}

function extractTextFromFiber(inst: ElementInstance): string | undefined {
const parts: string[] = [];
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: 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;
}
Comment thread
antonis marked this conversation as resolved.

const props = inst.memoizedProps;
if (typeof props === 'string') {
parts.push(props);
} else if (typeof props?.children === 'string') {
parts.push(props.children);
}

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
Expand Down
Loading
Loading