Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## 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
<Pressable
sentry-label="checkout"
sentry-span-attributes={{
'user.type': 'premium',
'cart.value': 150
}}
onPress={handleCheckout}>
<Text>Checkout</Text>
</Pressable>
```

## 7.10.0

### Fixes
Expand Down
52 changes: 51 additions & 1 deletion packages/core/src/js/touchevents.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, SpanAttributeValue>;
};

const touchEventStyles = StyleSheet.create({
Expand All @@ -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';
Expand Down Expand Up @@ -204,6 +212,28 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
});
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<string, SpanAttributeValue> | 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);
}
}
}

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

function getSpanAttributes(currentInst: ElementInstance): Record<string, SpanAttributeValue> | 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<string, SpanAttributeValue>;
}

return undefined;
}

/**
* Convenience Higher-Order-Component for TouchEventBoundary
* @param WrappedComponent any React Component
Expand Down
240 changes: 240 additions & 0 deletions packages/core/test/touchevents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventItem>(envelope, 'transaction'),
);
});
it('all navigation transaction envelopes have time to display measurements', async () => {
allTransactionEnvelopes
.filter(envelope => {
const item = getItemOfTypeFrom<EventItem>(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<EventItem>(envelope, 'transaction'),
);
});
});

function expectToContainTimeToDisplayMeasurements(
Expand Down
7 changes: 7 additions & 0 deletions samples/react-native/src/Screens/SpaceflightNewsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ export default function NewsScreen() {
}
return (
<Pressable
sentry-label="load-more-articles"
sentry-span-attributes={{
'articles.loaded': articles.length,
'pagination.page': page,
'pagination.next_page': page + 1,
'auto_load.count': autoLoadCount,
}}
style={({ pressed }) => [
styles.loadMoreButton,
pressed && styles.loadMoreButtonPressed,
Expand Down
Loading