diff --git a/CHANGELOG.md b/CHANGELOG.md
index 117ac56a40..dbfb633f8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,23 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
+## Unreleased
+
+### Features
+
+- Add experimental `sentry-span-attributes` prop to attach custom attributes to user interaction spans ([#5569](https://github.com/getsentry/sentry-react-native/pull/5569))
+ ```tsx
+
+ Checkout
+
+ ```
+
## 7.10.0
### Fixes
diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx
index c6eb78770c..907790e262 100644
--- a/packages/core/src/js/touchevents.tsx
+++ b/packages/core/src/js/touchevents.tsx
@@ -1,4 +1,4 @@
-import type { SeverityLevel } from '@sentry/core';
+import type { SeverityLevel, SpanAttributeValue } from '@sentry/core';
import { addBreadcrumb, debug, dropUndefinedKeys, getClient, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
import * as React from 'react';
import type { GestureResponderEvent } from 'react-native';
@@ -39,6 +39,13 @@ export type TouchEventBoundaryProps = {
* Label Name used to identify the touched element.
*/
labelName?: string;
+ /**
+ * Custom attributes to add to user interaction spans.
+ * Accepts an object with string keys and values that are strings, numbers, booleans, or arrays.
+ *
+ * @experimental This API is experimental and may change in future releases.
+ */
+ spanAttributes?: Record;
};
const touchEventStyles = StyleSheet.create({
@@ -52,6 +59,7 @@ const DEFAULT_BREADCRUMB_TYPE = 'user';
const DEFAULT_MAX_COMPONENT_TREE_SIZE = 20;
const SENTRY_LABEL_PROP_KEY = 'sentry-label';
+const SENTRY_SPAN_ATTRIBUTES_PROP_KEY = 'sentry-span-attributes';
const SENTRY_COMPONENT_PROP_KEY = 'data-sentry-component';
const SENTRY_ELEMENT_PROP_KEY = 'data-sentry-element';
const SENTRY_FILE_PROP_KEY = 'data-sentry-source-file';
@@ -204,6 +212,28 @@ class TouchEventBoundary extends React.Component {
});
if (span) {
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_INTERACTION);
+
+ // Apply custom attributes from sentry-span-attributes prop
+ // Traverse the component tree to find custom attributes
+ let instForAttributes: ElementInstance | undefined = e._targetInst;
+ let customAttributes: Record | undefined;
+
+ while (instForAttributes) {
+ if (instForAttributes.elementType?.displayName === TouchEventBoundary.displayName) {
+ break;
+ }
+
+ customAttributes = getSpanAttributes(instForAttributes);
+ if (customAttributes && Object.keys(customAttributes).length > 0) {
+ break;
+ }
+
+ instForAttributes = instForAttributes.return;
+ }
+
+ if (customAttributes && Object.keys(customAttributes).length > 0) {
+ span.setAttributes(customAttributes);
+ }
}
}
@@ -291,6 +321,26 @@ function getLabelValue(props: Record, labelKey: string | undefi
: undefined;
}
+function getSpanAttributes(currentInst: ElementInstance): Record | undefined {
+ if (!currentInst.memoizedProps) {
+ return undefined;
+ }
+
+ const props = currentInst.memoizedProps;
+ const attributes = props[SENTRY_SPAN_ATTRIBUTES_PROP_KEY];
+
+ // Validate that it's an object (not null, not array)
+ if (
+ typeof attributes === 'object' &&
+ attributes !== null &&
+ !Array.isArray(attributes)
+ ) {
+ return attributes as Record;
+ }
+
+ return undefined;
+}
+
/**
* 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 11c62e64d3..32089530b8 100644
--- a/packages/core/test/touchevents.test.tsx
+++ b/packages/core/test/touchevents.test.tsx
@@ -4,6 +4,7 @@
import type { SeverityLevel } from '@sentry/core';
import * as core from '@sentry/core';
import { TouchEventBoundary } from '../src/js/touchevents';
+import * as userInteractionModule from '../src/js/tracing/integrations/userInteraction';
import { getDefaultTestClientOptions, TestClient } from './mocks/client';
describe('TouchEventBoundary._onTouchStart', () => {
@@ -310,4 +311,243 @@ describe('TouchEventBoundary._onTouchStart', () => {
type: defaultProps.breadcrumbType,
});
});
+
+ describe('sentry-span-attributes', () => {
+ it('sets custom attributes from prop on user interaction span', () => {
+ const { defaultProps } = TouchEventBoundary;
+ const boundary = new TouchEventBoundary(defaultProps);
+
+ const mockSpan = {
+ setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
+ };
+ jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
+
+ const event = {
+ _targetInst: {
+ elementType: { displayName: 'Button' },
+ memoizedProps: {
+ 'sentry-label': 'checkout',
+ 'sentry-span-attributes': {
+ 'user.subscription': 'premium',
+ 'cart.items': '3',
+ 'feature.enabled': true,
+ },
+ },
+ },
+ };
+
+ // @ts-expect-error Calling private member
+ boundary._onTouchStart(event);
+
+ expect(mockSpan.setAttributes).toHaveBeenCalledWith({
+ 'user.subscription': 'premium',
+ 'cart.items': '3',
+ 'feature.enabled': true,
+ });
+ });
+
+ it('handles multiple attribute types (string, number, boolean)', () => {
+ const { defaultProps } = TouchEventBoundary;
+ const boundary = new TouchEventBoundary(defaultProps);
+
+ const mockSpan = {
+ setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
+ };
+ jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
+
+ const event = {
+ _targetInst: {
+ elementType: { displayName: 'Button' },
+ memoizedProps: {
+ 'sentry-label': 'test',
+ 'sentry-span-attributes': {
+ 'string.value': 'test',
+ 'number.value': 42,
+ 'boolean.value': false,
+ 'array.value': ['a', 'b', 'c'],
+ },
+ },
+ },
+ };
+
+ // @ts-expect-error Calling private member
+ boundary._onTouchStart(event);
+
+ expect(mockSpan.setAttributes).toHaveBeenCalledWith({
+ 'string.value': 'test',
+ 'number.value': 42,
+ 'boolean.value': false,
+ 'array.value': ['a', 'b', 'c'],
+ });
+ });
+
+ it('handles invalid span attributes gracefully (null)', () => {
+ const { defaultProps } = TouchEventBoundary;
+ const boundary = new TouchEventBoundary(defaultProps);
+
+ const mockSpan = {
+ setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
+ };
+ jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
+
+ const event = {
+ _targetInst: {
+ elementType: { displayName: 'Button' },
+ memoizedProps: {
+ 'sentry-label': 'test',
+ 'sentry-span-attributes': null,
+ },
+ },
+ };
+
+ // @ts-expect-error Calling private member
+ boundary._onTouchStart(event);
+
+ expect(mockSpan.setAttributes).not.toHaveBeenCalled();
+ });
+
+ it('handles invalid span attributes gracefully (array)', () => {
+ const { defaultProps } = TouchEventBoundary;
+ const boundary = new TouchEventBoundary(defaultProps);
+
+ const mockSpan = {
+ setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
+ };
+ jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
+
+ const event = {
+ _targetInst: {
+ elementType: { displayName: 'Button' },
+ memoizedProps: {
+ 'sentry-label': 'test',
+ 'sentry-span-attributes': ['invalid', 'array'],
+ },
+ },
+ };
+
+ // @ts-expect-error Calling private member
+ boundary._onTouchStart(event);
+
+ expect(mockSpan.setAttributes).not.toHaveBeenCalled();
+ });
+
+ it('handles empty object gracefully', () => {
+ const { defaultProps } = TouchEventBoundary;
+ const boundary = new TouchEventBoundary(defaultProps);
+
+ const mockSpan = {
+ setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
+ };
+ jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
+
+ const event = {
+ _targetInst: {
+ elementType: { displayName: 'Button' },
+ memoizedProps: {
+ 'sentry-label': 'test',
+ 'sentry-span-attributes': {},
+ },
+ },
+ };
+
+ // @ts-expect-error Calling private member
+ boundary._onTouchStart(event);
+
+ expect(mockSpan.setAttributes).not.toHaveBeenCalled();
+ });
+
+ it('works with sentry-label', () => {
+ const { defaultProps } = TouchEventBoundary;
+ const boundary = new TouchEventBoundary(defaultProps);
+
+ const mockSpan = {
+ setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
+ };
+ jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
+
+ const event = {
+ _targetInst: {
+ elementType: { displayName: 'Button' },
+ memoizedProps: {
+ 'sentry-label': 'checkout-button',
+ 'sentry-span-attributes': {
+ 'custom.key': 'value',
+ },
+ },
+ },
+ };
+
+ // @ts-expect-error Calling private member
+ boundary._onTouchStart(event);
+
+ expect(userInteractionModule.startUserInteractionSpan).toHaveBeenCalledWith({
+ elementId: 'checkout-button',
+ op: 'ui.action.touch',
+ });
+ expect(mockSpan.setAttributes).toHaveBeenCalledWith({
+ 'custom.key': 'value',
+ });
+ });
+
+ it('finds attributes in component tree', () => {
+ const { defaultProps } = TouchEventBoundary;
+ const boundary = new TouchEventBoundary(defaultProps);
+
+ const mockSpan = {
+ setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
+ };
+ jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(mockSpan as any);
+
+ const event = {
+ _targetInst: {
+ elementType: { displayName: 'Text' },
+ return: {
+ elementType: { displayName: 'Button' },
+ memoizedProps: {
+ 'sentry-label': 'parent-button',
+ 'sentry-span-attributes': {
+ 'found.in': 'parent',
+ },
+ },
+ },
+ },
+ };
+
+ // @ts-expect-error Calling private member
+ boundary._onTouchStart(event);
+
+ expect(mockSpan.setAttributes).toHaveBeenCalledWith({
+ 'found.in': 'parent',
+ });
+ });
+
+ it('does not call setAttributes when no span is created', () => {
+ const { defaultProps } = TouchEventBoundary;
+ const boundary = new TouchEventBoundary(defaultProps);
+
+ jest.spyOn(userInteractionModule, 'startUserInteractionSpan').mockReturnValue(undefined);
+
+ const event = {
+ _targetInst: {
+ elementType: { displayName: 'Button' },
+ memoizedProps: {
+ 'sentry-label': 'test',
+ 'sentry-span-attributes': {
+ 'custom.key': 'value',
+ },
+ },
+ },
+ };
+
+ // @ts-expect-error Calling private member
+ expect(() => boundary._onTouchStart(event)).not.toThrow();
+ });
+ });
});
diff --git a/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts b/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts
index 2bdd30486e..6e9d6be8b5 100644
--- a/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts
+++ b/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts
@@ -55,12 +55,19 @@ describe('Capture Spaceflight News Screen Transaction', () => {
expect(first![1].timestamp!).toBeLessThan(second![1].timestamp!);
});
- it('all transaction envelopes have time to display measurements', async () => {
- allTransactionEnvelopes.forEach(envelope => {
- expectToContainTimeToDisplayMeasurements(
- getItemOfTypeFrom(envelope, 'transaction'),
- );
- });
+ it('all navigation transaction envelopes have time to display measurements', async () => {
+ allTransactionEnvelopes
+ .filter(envelope => {
+ const item = getItemOfTypeFrom(envelope, 'transaction');
+ // Only check navigation transactions, not user interaction transactions
+ // User interaction transactions (ui.action.touch) don't have time-to-display measurements
+ return item?.[1]?.contexts?.trace?.op !== 'ui.action.touch';
+ })
+ .forEach(envelope => {
+ expectToContainTimeToDisplayMeasurements(
+ getItemOfTypeFrom(envelope, 'transaction'),
+ );
+ });
});
function expectToContainTimeToDisplayMeasurements(
diff --git a/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx b/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx
index f03f9b76fb..e7830d2aa3 100644
--- a/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx
+++ b/samples/react-native/src/Screens/SpaceflightNewsScreen.tsx
@@ -106,6 +106,13 @@ export default function NewsScreen() {
}
return (
[
styles.loadMoreButton,
pressed && styles.loadMoreButtonPressed,