Skip to content

Commit bde0bf2

Browse files
committed
feat(tracing): Add sentry-span-attributes prop for custom span attributes
1 parent 59d1977 commit bde0bf2

4 files changed

Lines changed: 312 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@
66
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
77
<!-- prettier-ignore-end -->
88
9+
## Unreleased
10+
11+
### Features
12+
13+
- Add `sentry-span-attributes` prop to attach custom attributes to user interaction spans ([#5568](https://github.com/getsentry/sentry-react-native/pull/5568))
14+
```tsx
15+
<Button
16+
sentry-label="checkout"
17+
sentry-span-attributes={{
18+
'user.type': 'premium',
19+
'cart.value': 150
20+
}}
21+
onPress={handleCheckout}
22+
/>
23+
```
24+
925
## 7.10.0
1026

1127
### Fixes

packages/core/src/js/touchevents.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { SeverityLevel } from '@sentry/core';
1+
import type { SeverityLevel, SpanAttributeValue } from '@sentry/core';
22
import { addBreadcrumb, debug, dropUndefinedKeys, getClient, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
33
import * as React from 'react';
44
import type { GestureResponderEvent } from 'react-native';
@@ -39,6 +39,11 @@ export type TouchEventBoundaryProps = {
3939
* Label Name used to identify the touched element.
4040
*/
4141
labelName?: string;
42+
/**
43+
* Custom attributes to add to user interaction spans.
44+
* Accepts an object with string keys and values that are strings, numbers, booleans, or arrays.
45+
*/
46+
spanAttributes?: Record<string, SpanAttributeValue>;
4247
};
4348

4449
const touchEventStyles = StyleSheet.create({
@@ -52,6 +57,7 @@ const DEFAULT_BREADCRUMB_TYPE = 'user';
5257
const DEFAULT_MAX_COMPONENT_TREE_SIZE = 20;
5358

5459
const SENTRY_LABEL_PROP_KEY = 'sentry-label';
60+
const SENTRY_SPAN_ATTRIBUTES_PROP_KEY = 'sentry-span-attributes';
5561
const SENTRY_COMPONENT_PROP_KEY = 'data-sentry-component';
5662
const SENTRY_ELEMENT_PROP_KEY = 'data-sentry-element';
5763
const SENTRY_FILE_PROP_KEY = 'data-sentry-source-file';
@@ -204,6 +210,28 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
204210
});
205211
if (span) {
206212
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_INTERACTION);
213+
214+
// Apply custom attributes from sentry-span-attributes prop
215+
// Traverse the component tree to find custom attributes
216+
let instForAttributes: ElementInstance | undefined = e._targetInst;
217+
let customAttributes: Record<string, SpanAttributeValue> | undefined;
218+
219+
while (instForAttributes) {
220+
if (instForAttributes.elementType?.displayName === TouchEventBoundary.displayName) {
221+
break;
222+
}
223+
224+
customAttributes = getSpanAttributes(instForAttributes);
225+
if (customAttributes && Object.keys(customAttributes).length > 0) {
226+
break;
227+
}
228+
229+
instForAttributes = instForAttributes.return;
230+
}
231+
232+
if (customAttributes && Object.keys(customAttributes).length > 0) {
233+
span.setAttributes(customAttributes);
234+
}
207235
}
208236
}
209237

@@ -291,6 +319,26 @@ function getLabelValue(props: Record<string, unknown>, labelKey: string | undefi
291319
: undefined;
292320
}
293321

322+
function getSpanAttributes(currentInst: ElementInstance): Record<string, SpanAttributeValue> | undefined {
323+
if (!currentInst.memoizedProps) {
324+
return undefined;
325+
}
326+
327+
const props = currentInst.memoizedProps;
328+
const attributes = props[SENTRY_SPAN_ATTRIBUTES_PROP_KEY];
329+
330+
// Validate that it's an object (not null, not array)
331+
if (
332+
typeof attributes === 'object' &&
333+
attributes !== null &&
334+
!Array.isArray(attributes)
335+
) {
336+
return attributes as Record<string, SpanAttributeValue>;
337+
}
338+
339+
return undefined;
340+
}
341+
294342
/**
295343
* Convenience Higher-Order-Component for TouchEventBoundary
296344
* @param WrappedComponent any React Component

packages/core/test/touchevents.test.tsx

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import type { SeverityLevel } from '@sentry/core';
55
import * as core from '@sentry/core';
66
import { TouchEventBoundary } from '../src/js/touchevents';
7+
import * as userInteractionModule from '../src/js/tracing/integrations/userInteraction';
78
import { getDefaultTestClientOptions, TestClient } from './mocks/client';
89

910
describe('TouchEventBoundary._onTouchStart', () => {
@@ -310,4 +311,243 @@ describe('TouchEventBoundary._onTouchStart', () => {
310311
type: defaultProps.breadcrumbType,
311312
});
312313
});
314+
315+
describe('sentry-span-attributes', () => {
316+
it('sets custom attributes from prop on user interaction span', () => {
317+
const { defaultProps } = TouchEventBoundary;
318+
const boundary = new TouchEventBoundary(defaultProps);
319+
320+
const mockSpan = {
321+
setAttribute: jest.fn(),
322+
setAttributes: jest.fn(),
323+
};
324+
jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
325+
326+
const event = {
327+
_targetInst: {
328+
elementType: { displayName: 'Button' },
329+
memoizedProps: {
330+
'sentry-label': 'checkout',
331+
'sentry-span-attributes': {
332+
'user.subscription': 'premium',
333+
'cart.items': '3',
334+
'feature.enabled': true,
335+
},
336+
},
337+
},
338+
};
339+
340+
// @ts-expect-error Calling private member
341+
boundary._onTouchStart(event);
342+
343+
expect(mockSpan.setAttributes).toHaveBeenCalledWith({
344+
'user.subscription': 'premium',
345+
'cart.items': '3',
346+
'feature.enabled': true,
347+
});
348+
});
349+
350+
it('handles multiple attribute types (string, number, boolean)', () => {
351+
const { defaultProps } = TouchEventBoundary;
352+
const boundary = new TouchEventBoundary(defaultProps);
353+
354+
const mockSpan = {
355+
setAttribute: jest.fn(),
356+
setAttributes: jest.fn(),
357+
};
358+
jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
359+
360+
const event = {
361+
_targetInst: {
362+
elementType: { displayName: 'Button' },
363+
memoizedProps: {
364+
'sentry-label': 'test',
365+
'sentry-span-attributes': {
366+
'string.value': 'test',
367+
'number.value': 42,
368+
'boolean.value': false,
369+
'array.value': ['a', 'b', 'c'],
370+
},
371+
},
372+
},
373+
};
374+
375+
// @ts-expect-error Calling private member
376+
boundary._onTouchStart(event);
377+
378+
expect(mockSpan.setAttributes).toHaveBeenCalledWith({
379+
'string.value': 'test',
380+
'number.value': 42,
381+
'boolean.value': false,
382+
'array.value': ['a', 'b', 'c'],
383+
});
384+
});
385+
386+
it('handles invalid span attributes gracefully (null)', () => {
387+
const { defaultProps } = TouchEventBoundary;
388+
const boundary = new TouchEventBoundary(defaultProps);
389+
390+
const mockSpan = {
391+
setAttribute: jest.fn(),
392+
setAttributes: jest.fn(),
393+
};
394+
jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
395+
396+
const event = {
397+
_targetInst: {
398+
elementType: { displayName: 'Button' },
399+
memoizedProps: {
400+
'sentry-label': 'test',
401+
'sentry-span-attributes': null,
402+
},
403+
},
404+
};
405+
406+
// @ts-expect-error Calling private member
407+
boundary._onTouchStart(event);
408+
409+
expect(mockSpan.setAttributes).not.toHaveBeenCalled();
410+
});
411+
412+
it('handles invalid span attributes gracefully (array)', () => {
413+
const { defaultProps } = TouchEventBoundary;
414+
const boundary = new TouchEventBoundary(defaultProps);
415+
416+
const mockSpan = {
417+
setAttribute: jest.fn(),
418+
setAttributes: jest.fn(),
419+
};
420+
jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
421+
422+
const event = {
423+
_targetInst: {
424+
elementType: { displayName: 'Button' },
425+
memoizedProps: {
426+
'sentry-label': 'test',
427+
'sentry-span-attributes': ['invalid', 'array'],
428+
},
429+
},
430+
};
431+
432+
// @ts-expect-error Calling private member
433+
boundary._onTouchStart(event);
434+
435+
expect(mockSpan.setAttributes).not.toHaveBeenCalled();
436+
});
437+
438+
it('handles empty object gracefully', () => {
439+
const { defaultProps } = TouchEventBoundary;
440+
const boundary = new TouchEventBoundary(defaultProps);
441+
442+
const mockSpan = {
443+
setAttribute: jest.fn(),
444+
setAttributes: jest.fn(),
445+
};
446+
jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
447+
448+
const event = {
449+
_targetInst: {
450+
elementType: { displayName: 'Button' },
451+
memoizedProps: {
452+
'sentry-label': 'test',
453+
'sentry-span-attributes': {},
454+
},
455+
},
456+
};
457+
458+
// @ts-expect-error Calling private member
459+
boundary._onTouchStart(event);
460+
461+
expect(mockSpan.setAttributes).not.toHaveBeenCalled();
462+
});
463+
464+
it('works with sentry-label', () => {
465+
const { defaultProps } = TouchEventBoundary;
466+
const boundary = new TouchEventBoundary(defaultProps);
467+
468+
const mockSpan = {
469+
setAttribute: jest.fn(),
470+
setAttributes: jest.fn(),
471+
};
472+
jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
473+
474+
const event = {
475+
_targetInst: {
476+
elementType: { displayName: 'Button' },
477+
memoizedProps: {
478+
'sentry-label': 'checkout-button',
479+
'sentry-span-attributes': {
480+
'custom.key': 'value',
481+
},
482+
},
483+
},
484+
};
485+
486+
// @ts-expect-error Calling private member
487+
boundary._onTouchStart(event);
488+
489+
expect(userInteractionModule.startUserInteractionSpan).toHaveBeenCalledWith({
490+
elementId: 'checkout-button',
491+
op: 'ui.action.touch',
492+
});
493+
expect(mockSpan.setAttributes).toHaveBeenCalledWith({
494+
'custom.key': 'value',
495+
});
496+
});
497+
498+
it('finds attributes in component tree', () => {
499+
const { defaultProps } = TouchEventBoundary;
500+
const boundary = new TouchEventBoundary(defaultProps);
501+
502+
const mockSpan = {
503+
setAttribute: jest.fn(),
504+
setAttributes: jest.fn(),
505+
};
506+
jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
507+
508+
const event = {
509+
_targetInst: {
510+
elementType: { displayName: 'Text' },
511+
return: {
512+
elementType: { displayName: 'Button' },
513+
memoizedProps: {
514+
'sentry-label': 'parent-button',
515+
'sentry-span-attributes': {
516+
'found.in': 'parent',
517+
},
518+
},
519+
},
520+
},
521+
};
522+
523+
// @ts-expect-error Calling private member
524+
boundary._onTouchStart(event);
525+
526+
expect(mockSpan.setAttributes).toHaveBeenCalledWith({
527+
'found.in': 'parent',
528+
});
529+
});
530+
531+
it('does not call setAttributes when no span is created', () => {
532+
const { defaultProps } = TouchEventBoundary;
533+
const boundary = new TouchEventBoundary(defaultProps);
534+
535+
jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(undefined);
536+
537+
const event = {
538+
_targetInst: {
539+
elementType: { displayName: 'Button' },
540+
memoizedProps: {
541+
'sentry-label': 'test',
542+
'sentry-span-attributes': {
543+
'custom.key': 'value',
544+
},
545+
},
546+
},
547+
};
548+
549+
// @ts-expect-error Calling private member
550+
expect(() => boundary._onTouchStart(event)).not.toThrow();
551+
});
552+
});
313553
});

samples/react-native/src/Screens/SpaceflightNewsScreen.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ export default function NewsScreen() {
106106
}
107107
return (
108108
<Pressable
109+
sentry-label="load-more-articles"
110+
sentry-span-attributes={{
111+
'articles.loaded': articles.length,
112+
'pagination.page': page,
113+
'pagination.next_page': page + 1,
114+
'auto_load.count': autoLoadCount,
115+
}}
109116
style={({ pressed }) => [
110117
styles.loadMoreButton,
111118
pressed && styles.loadMoreButtonPressed,

0 commit comments

Comments
 (0)