Skip to content

Commit d9f44bb

Browse files
antonisclaude
andauthored
feat(tracing): Add sentry-span-attributes prop for custom span attributes (#5569)
* feat(tracing): Add sentry-span-attributes prop for custom span attributes * Mark the api as experimental * Update changelog PR ref * Update changelog * test(e2e): Filter user interaction transactions from time-to-display test The SpaceflightNewsScreen sample now includes a button with sentry-label, which creates ui.action.touch transactions. These user interaction transactions don't have time-to-display measurements (only navigation transactions do). Updated the test to filter out ui.action.touch transactions before checking for time-to-display measurements, allowing the test to correctly validate navigation transactions while ignoring user interaction transactions. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent c637fc7 commit d9f44bb

File tree

5 files changed

+328
-7
lines changed

5 files changed

+328
-7
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@
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 experimental `sentry-span-attributes` prop to attach custom attributes to user interaction spans ([#5569](https://github.com/getsentry/sentry-react-native/pull/5569))
14+
```tsx
15+
<Pressable
16+
sentry-label="checkout"
17+
sentry-span-attributes={{
18+
'user.type': 'premium',
19+
'cart.value': 150
20+
}}
21+
onPress={handleCheckout}>
22+
<Text>Checkout</Text>
23+
</Pressable>
24+
```
25+
926
## 7.10.0
1027

1128
### Fixes

packages/core/src/js/touchevents.tsx

Lines changed: 51 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,13 @@ 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+
* @experimental This API is experimental and may change in future releases.
47+
*/
48+
spanAttributes?: Record<string, SpanAttributeValue>;
4249
};
4350

4451
const touchEventStyles = StyleSheet.create({
@@ -52,6 +59,7 @@ const DEFAULT_BREADCRUMB_TYPE = 'user';
5259
const DEFAULT_MAX_COMPONENT_TREE_SIZE = 20;
5360

5461
const SENTRY_LABEL_PROP_KEY = 'sentry-label';
62+
const SENTRY_SPAN_ATTRIBUTES_PROP_KEY = 'sentry-span-attributes';
5563
const SENTRY_COMPONENT_PROP_KEY = 'data-sentry-component';
5664
const SENTRY_ELEMENT_PROP_KEY = 'data-sentry-element';
5765
const SENTRY_FILE_PROP_KEY = 'data-sentry-source-file';
@@ -204,6 +212,28 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
204212
});
205213
if (span) {
206214
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_INTERACTION);
215+
216+
// Apply custom attributes from sentry-span-attributes prop
217+
// Traverse the component tree to find custom attributes
218+
let instForAttributes: ElementInstance | undefined = e._targetInst;
219+
let customAttributes: Record<string, SpanAttributeValue> | undefined;
220+
221+
while (instForAttributes) {
222+
if (instForAttributes.elementType?.displayName === TouchEventBoundary.displayName) {
223+
break;
224+
}
225+
226+
customAttributes = getSpanAttributes(instForAttributes);
227+
if (customAttributes && Object.keys(customAttributes).length > 0) {
228+
break;
229+
}
230+
231+
instForAttributes = instForAttributes.return;
232+
}
233+
234+
if (customAttributes && Object.keys(customAttributes).length > 0) {
235+
span.setAttributes(customAttributes);
236+
}
207237
}
208238
}
209239

@@ -291,6 +321,26 @@ function getLabelValue(props: Record<string, unknown>, labelKey: string | undefi
291321
: undefined;
292322
}
293323

324+
function getSpanAttributes(currentInst: ElementInstance): Record<string, SpanAttributeValue> | undefined {
325+
if (!currentInst.memoizedProps) {
326+
return undefined;
327+
}
328+
329+
const props = currentInst.memoizedProps;
330+
const attributes = props[SENTRY_SPAN_ATTRIBUTES_PROP_KEY];
331+
332+
// Validate that it's an object (not null, not array)
333+
if (
334+
typeof attributes === 'object' &&
335+
attributes !== null &&
336+
!Array.isArray(attributes)
337+
) {
338+
return attributes as Record<string, SpanAttributeValue>;
339+
}
340+
341+
return undefined;
342+
}
343+
294344
/**
295345
* Convenience Higher-Order-Component for TouchEventBoundary
296346
* @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/e2e/captureSpaceflightNewsScreenTransaction.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,19 @@ describe('Capture Spaceflight News Screen Transaction', () => {
5555
expect(first![1].timestamp!).toBeLessThan(second![1].timestamp!);
5656
});
5757

58-
it('all transaction envelopes have time to display measurements', async () => {
59-
allTransactionEnvelopes.forEach(envelope => {
60-
expectToContainTimeToDisplayMeasurements(
61-
getItemOfTypeFrom<EventItem>(envelope, 'transaction'),
62-
);
63-
});
58+
it('all navigation transaction envelopes have time to display measurements', async () => {
59+
allTransactionEnvelopes
60+
.filter(envelope => {
61+
const item = getItemOfTypeFrom<EventItem>(envelope, 'transaction');
62+
// Only check navigation transactions, not user interaction transactions
63+
// User interaction transactions (ui.action.touch) don't have time-to-display measurements
64+
return item?.[1]?.contexts?.trace?.op !== 'ui.action.touch';
65+
})
66+
.forEach(envelope => {
67+
expectToContainTimeToDisplayMeasurements(
68+
getItemOfTypeFrom<EventItem>(envelope, 'transaction'),
69+
);
70+
});
6471
});
6572

6673
function expectToContainTimeToDisplayMeasurements(

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)