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,