Skip to content

Commit b063a5e

Browse files
committed
feat: support annotated components in touch evetns
1 parent bc036a7 commit b063a5e

File tree

8 files changed

+151
-60
lines changed

8 files changed

+151
-60
lines changed

samples/expo/babel.config.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
module.exports = function(api) {
1+
const componentAnnotatePlugin = require('@sentry/babel-plugin-component-annotate');
2+
3+
module.exports = function (api) {
24
api.cache(false);
35
return {
46
presets: ['babel-preset-expo'],
@@ -11,6 +13,7 @@ module.exports = function(api) {
1113
},
1214
},
1315
],
16+
componentAnnotatePlugin,
1417
],
1518
};
1619
};

samples/expo/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"devDependencies": {
3434
"@babel/core": "^7.20.0",
3535
"@babel/preset-env": "7.1.6",
36+
"@sentry/babel-plugin-component-annotate": "^2.18.0",
3637
"@types/node": "20.10.4"
3738
},
3839
"overrides": {

samples/expo/yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -2413,6 +2413,11 @@
24132413
component-type "^1.2.1"
24142414
join-component "^1.1.0"
24152415

2416+
"@sentry/babel-plugin-component-annotate@^2.18.0":
2417+
version "2.18.0"
2418+
resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.18.0.tgz#3bee98f94945643b0762ceed1f6cca60db52bdbd"
2419+
integrity sha512-9L4RbhS3WNtc/SokIhc0dwgcvs78YSQPakZejsrIgnzLzCi8mS6PeT+BY0+QCtsXxjd1egM8hqcJeB0lukBkXA==
2420+
24162421
"@sideway/address@^4.1.3":
24172422
version "4.1.4"
24182423
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.4.tgz#03dccebc6ea47fdc226f7d3d1ad512955d4783f0"

samples/react-native/babel.config.js

+3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const componentAnnotatePlugin = require('@sentry/babel-plugin-component-annotate');
2+
13
module.exports = {
24
presets: ['module:@react-native/babel-preset'],
35
plugins: [
@@ -9,5 +11,6 @@ module.exports = {
911
},
1012
},
1113
],
14+
componentAnnotatePlugin,
1215
],
1316
};

samples/react-native/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@react-native/eslint-config": "^0.73.1",
4444
"@react-native/metro-config": "^0.73.1",
4545
"@react-native/typescript-config": "^0.73.1",
46+
"@sentry/babel-plugin-component-annotate": "^2.18.0",
4647
"@types/react": "^18.2.65",
4748
"@types/react-native-vector-icons": "^6.4.18",
4849
"@types/react-test-renderer": "^18.0.0",

samples/react-native/yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -3127,6 +3127,11 @@
31273127
color "^4.2.3"
31283128
warn-once "^0.1.0"
31293129

3130+
"@sentry/babel-plugin-component-annotate@^2.18.0":
3131+
version "2.18.0"
3132+
resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.18.0.tgz#3bee98f94945643b0762ceed1f6cca60db52bdbd"
3133+
integrity sha512-9L4RbhS3WNtc/SokIhc0dwgcvs78YSQPakZejsrIgnzLzCi8mS6PeT+BY0+QCtsXxjd1egM8hqcJeB0lukBkXA==
3134+
31303135
"@sideway/address@^4.1.3":
31313136
version "4.1.4"
31323137
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.4.tgz#03dccebc6ea47fdc226f7d3d1ad512955d4783f0"

src/js/touchevents.tsx

+65-54
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { addBreadcrumb, getCurrentHub } from '@sentry/core';
22
import type { SeverityLevel } from '@sentry/types';
33
import { logger } from '@sentry/utils';
44
import * as React from 'react';
5-
import type { GestureResponderEvent} from 'react-native';
5+
import type { GestureResponderEvent } from 'react-native';
66
import { StyleSheet, View } from 'react-native';
77

88
import { createIntegration } from './integrations/factory';
@@ -53,6 +53,9 @@ const DEFAULT_BREADCRUMB_TYPE = 'user';
5353
const DEFAULT_MAX_COMPONENT_TREE_SIZE = 20;
5454

5555
const SENTRY_LABEL_PROP_KEY = 'sentry-label';
56+
const SENTRY_COMPONENT_PROP_KEY = 'data-sentry-component';
57+
const SENTRY_ELEMENT_PROP_KEY = 'data-sentry-element';
58+
const SENTRY_FILE_PROP_KEY = 'data-sentry-source-file';
5659

5760
interface ElementInstance {
5861
elementType?: {
@@ -63,6 +66,13 @@ interface ElementInstance {
6366
return?: ElementInstance;
6467
}
6568

69+
interface TouchedComponentInfo {
70+
name?: string;
71+
label?: string;
72+
element?: string;
73+
file?: string;
74+
}
75+
6676
interface PrivateGestureResponderEvent extends GestureResponderEvent {
6777
_targetInst?: ElementInstance;
6878
}
@@ -71,7 +81,6 @@ interface PrivateGestureResponderEvent extends GestureResponderEvent {
7181
* Boundary to log breadcrumbs for interaction events.
7282
*/
7383
class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
74-
7584
public static displayName: string = '__Sentry.TouchEventBoundary';
7685
public static defaultProps: Partial<TouchEventBoundaryProps> = {
7786
breadcrumbCategory: DEFAULT_BREADCRUMB_CATEGORY,
@@ -113,18 +122,17 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
113122
/**
114123
* Logs the touch event given the component tree names and a label.
115124
*/
116-
private _logTouchEvent(
117-
componentTreeNames: string[],
118-
activeLabel?: string
119-
): void {
125+
private _logTouchEvent(touchPath: TouchedComponentInfo[], label?: string): void {
120126
const level = 'info' as SeverityLevel;
127+
128+
const root = touchPath[0];
129+
const detail = label ? label : `${root.name}${root.file ? ` (${root.file})` : ''}`;
130+
121131
const crumb = {
122132
category: this.props.breadcrumbCategory,
123-
data: { componentTree: componentTreeNames },
133+
data: { path: touchPath },
124134
level: level,
125-
message: activeLabel
126-
? `Touch event within element: ${activeLabel}`
127-
: 'Touch event within component tree',
135+
message: `Touch event within element: ${detail}`,
128136
type: this.props.breadcrumbType,
129137
};
130138
addBreadcrumb(crumb);
@@ -147,7 +155,7 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
147155
return ignoreNames.some(
148156
(ignoreName: string | RegExp) =>
149157
(typeof ignoreName === 'string' && name === ignoreName) ||
150-
(ignoreName instanceof RegExp && name.match(ignoreName))
158+
(ignoreName instanceof RegExp && name.match(ignoreName)),
151159
);
152160
}
153161

@@ -166,77 +174,80 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
166174
}
167175

168176
let currentInst: ElementInstance | undefined = e._targetInst;
169-
170-
let activeLabel: string | undefined;
171-
let activeDisplayName: string | undefined;
172-
const componentTreeNames: string[] = [];
177+
const touchPath: TouchedComponentInfo[] = [];
173178

174179
while (
175180
currentInst &&
176181
// maxComponentTreeSize will always be defined as we have a defaultProps. But ts needs a check so this is here.
177182
this.props.maxComponentTreeSize &&
178-
componentTreeNames.length < this.props.maxComponentTreeSize
183+
touchPath.length < this.props.maxComponentTreeSize
179184
) {
180185
if (
181186
// If the loop gets to the boundary itself, break.
182-
currentInst.elementType?.displayName ===
183-
TouchEventBoundary.displayName
187+
currentInst.elementType?.displayName === TouchEventBoundary.displayName
184188
) {
185189
break;
186190
}
187191

188-
const props = currentInst.memoizedProps;
192+
const props = currentInst.memoizedProps ?? {};
193+
const info: TouchedComponentInfo = {};
194+
if (typeof props[SENTRY_COMPONENT_PROP_KEY] === 'string' && props[SENTRY_COMPONENT_PROP_KEY].length > 0) {
195+
info.name = props[SENTRY_COMPONENT_PROP_KEY];
196+
}
197+
if (typeof props[SENTRY_ELEMENT_PROP_KEY] === 'string' && props[SENTRY_ELEMENT_PROP_KEY].length > 0) {
198+
info.element = props[SENTRY_ELEMENT_PROP_KEY];
199+
}
200+
if (typeof props[SENTRY_FILE_PROP_KEY] === 'string' && props[SENTRY_FILE_PROP_KEY].length > 0) {
201+
info.file = props[SENTRY_FILE_PROP_KEY];
202+
}
203+
189204
const labelValue =
190-
typeof props?.[SENTRY_LABEL_PROP_KEY] !== 'undefined'
191-
? `${props[SENTRY_LABEL_PROP_KEY]}`
192-
// For some reason type narrowing doesn't work as expected with indexing when checking it all in one go in
193-
// the "check-label" if sentence, so we have to assign it to a variable here first
194-
: (typeof this.props.labelName === 'string') ? props?.[this.props.labelName] : undefined;
195-
196-
// Check the label first
197-
if (labelValue && typeof labelValue === 'string') {
198-
if (this._pushIfNotIgnored(componentTreeNames, labelValue)) {
199-
if (!activeLabel) {
200-
activeLabel = labelValue;
201-
}
202-
}
203-
} else if (currentInst.elementType) {
204-
const { elementType } = currentInst;
205-
206-
// Check display name
207-
if (elementType.displayName) {
208-
if (this._pushIfNotIgnored(componentTreeNames, elementType.displayName)) {
209-
if (!activeDisplayName) {
210-
activeDisplayName = elementType.displayName;
211-
}
212-
}
213-
}
205+
typeof props[SENTRY_LABEL_PROP_KEY] === 'string'
206+
? props[SENTRY_LABEL_PROP_KEY]
207+
: // For some reason type narrowing doesn't work as expected with indexing when checking it all in one go in
208+
// the "check-label" if sentence, so we have to assign it to a variable here first
209+
typeof this.props.labelName === 'string'
210+
? props[this.props.labelName]
211+
: undefined;
212+
213+
if (typeof labelValue === 'string' && labelValue.length > 0) {
214+
info.label = labelValue;
215+
}
216+
217+
if (!info.name && currentInst.elementType?.displayName) {
218+
info.name = currentInst.elementType?.displayName;
214219
}
215220

221+
this._pushIfNotIgnored(touchPath, info);
222+
216223
currentInst = currentInst.return;
217224
}
218225

219-
const finalLabel = activeLabel ?? activeDisplayName;
220-
221-
if (componentTreeNames.length > 0 || finalLabel) {
222-
this._logTouchEvent(componentTreeNames, finalLabel);
226+
const label = touchPath.find(info => info.label)?.label;
227+
if (touchPath.length > 0) {
228+
this._logTouchEvent(touchPath, label);
223229
}
224230

225231
this._tracingIntegration?.startUserInteractionTransaction({
226-
elementId: activeLabel,
232+
elementId: label,
227233
op: UI_ACTION_TOUCH,
228234
});
229235
}
230236

231237
/**
232238
* Pushes the name to the componentTreeNames array if it is not ignored.
233239
*/
234-
private _pushIfNotIgnored(componentTreeNames: string[], name: string, file?: string): boolean {
235-
const value = file ? `${name} (${file})` : name;
236-
if (this._isNameIgnored(value)) {
240+
private _pushIfNotIgnored(touchPath: TouchedComponentInfo[], value: TouchedComponentInfo): boolean {
241+
if (!value.name && !value.label) {
242+
return false;
243+
}
244+
if (value.name && this._isNameIgnored(value.name)) {
245+
return false;
246+
}
247+
if (value.label && this._isNameIgnored(value.label)) {
237248
return false;
238249
}
239-
componentTreeNames.push(value);
250+
touchPath.push(value);
240251
return true;
241252
}
242253
}
@@ -249,9 +260,9 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
249260
const withTouchEventBoundary = (
250261
// eslint-disable-next-line @typescript-eslint/no-explicit-any
251262
InnerComponent: React.ComponentType<any>,
252-
boundaryProps?: TouchEventBoundaryProps
263+
boundaryProps?: TouchEventBoundaryProps,
253264
): React.FunctionComponent => {
254-
const WrappedComponent: React.FunctionComponent = (props) => (
265+
const WrappedComponent: React.FunctionComponent = props => (
255266
<TouchEventBoundary {...(boundaryProps ?? {})}>
256267
<InnerComponent {...props} />
257268
</TouchEventBoundary>

test/touchevents.test.tsx

+67-5
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as core from '@sentry/core';
55
import type { SeverityLevel } from '@sentry/types';
66

77
import { TouchEventBoundary } from '../src/js/touchevents';
8-
import { getDefaultTestClientOptions,TestClient } from './mocks/client';
8+
import { getDefaultTestClientOptions, TestClient } from './mocks/client';
99

1010
describe('TouchEventBoundary._onTouchStart', () => {
1111
let addBreadcrumb: jest.SpyInstance;
@@ -101,7 +101,7 @@ describe('TouchEventBoundary._onTouchStart', () => {
101101
expect(addBreadcrumb).toBeCalledWith({
102102
category: defaultProps.breadcrumbCategory,
103103
data: {
104-
componentTree: ['View', 'Connect(View)', 'LABEL!'],
104+
path: [{ name: 'View' }, { name: 'Connect(View)' }, { label: 'LABEL!' }],
105105
},
106106
level: 'info' as SeverityLevel,
107107
message: 'Touch event within element: LABEL!',
@@ -160,15 +160,15 @@ describe('TouchEventBoundary._onTouchStart', () => {
160160
expect(addBreadcrumb).toBeCalledWith({
161161
category: defaultProps.breadcrumbCategory,
162162
data: {
163-
componentTree: ['Styled(View2)', 'Styled(View)'],
163+
path: [{ name: 'Styled(View)' }],
164164
},
165165
level: 'info' as SeverityLevel,
166166
message: 'Touch event within element: Styled(View)',
167167
type: defaultProps.breadcrumbType,
168168
});
169169
});
170170

171-
it('maxComponentTreeSize', () => {
171+
it('maxpathSize', () => {
172172
const { defaultProps } = TouchEventBoundary;
173173
const boundary = new TouchEventBoundary({
174174
...defaultProps,
@@ -210,11 +210,73 @@ describe('TouchEventBoundary._onTouchStart', () => {
210210
expect(addBreadcrumb).toBeCalledWith({
211211
category: defaultProps.breadcrumbCategory,
212212
data: {
213-
componentTree: ['Connect(View)', 'Styled(View)'],
213+
path: [{ label: 'Connect(View)' }, { name: 'Styled(View)' }],
214214
},
215215
level: 'info' as SeverityLevel,
216216
message: 'Touch event within element: Connect(View)',
217217
type: defaultProps.breadcrumbType,
218218
});
219219
});
220+
221+
// see https://docs.sentry.io/platforms/javascript/guides/react/features/component-names/
222+
it('uses custom names provided by babel plugin', () => {
223+
const { defaultProps } = TouchEventBoundary;
224+
const boundary = new TouchEventBoundary(defaultProps);
225+
226+
const event = {
227+
_targetInst: {
228+
elementType: {
229+
displayName: 'View',
230+
},
231+
memoizedProps: {
232+
'data-sentry-component': 'Screen',
233+
'data-sentry-element': 'AnimatedNativeScreen',
234+
'data-sentry-source-file': 'screen.tsx',
235+
},
236+
return: {
237+
elementType: {
238+
displayName: 'Text',
239+
},
240+
return: {
241+
memoizedProps: {
242+
'custom-sentry-label-name': 'Connect(View)',
243+
'data-sentry-component': 'MyView',
244+
'data-sentry-source-file': 'myview.tsx',
245+
},
246+
return: {
247+
elementType: {
248+
displayName: 'Styled(View)',
249+
},
250+
return: {
251+
memoizedProps: {
252+
'data-sentry-component': 'Happy',
253+
'data-sentry-element': 'View',
254+
'data-sentry-source-file': 'happyview.js',
255+
},
256+
},
257+
},
258+
},
259+
},
260+
},
261+
};
262+
263+
// @ts-expect-error Calling private member
264+
boundary._onTouchStart(event);
265+
266+
expect(addBreadcrumb).toBeCalledWith({
267+
category: defaultProps.breadcrumbCategory,
268+
data: {
269+
path: [
270+
{ element: 'AnimatedNativeScreen', file: 'screen.tsx', name: 'Screen' },
271+
{ name: 'Text' },
272+
{ file: 'myview.tsx', name: 'MyView' },
273+
{ name: 'Styled(View)' },
274+
{ element: 'View', file: 'happyview.js', name: 'Happy' },
275+
],
276+
},
277+
level: 'info' as SeverityLevel,
278+
message: 'Touch event within element: Screen (screen.tsx)',
279+
type: defaultProps.breadcrumbType,
280+
});
281+
});
220282
});

0 commit comments

Comments
 (0)