Skip to content

Commit 374f3bf

Browse files
Merge 170822a into 52a8031
2 parents 52a8031 + 170822a commit 374f3bf

9 files changed

Lines changed: 426 additions & 72 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features
66

77
- Add App Context `in_foreground` ([#2826](https://github.com/getsentry/sentry-react-native/pull/2826))
8+
- Add User Interaction Tracing for Touch events ([#2835](https://github.com/getsentry/sentry-react-native/pull/2835))
89

910
### Fixes
1011

sample-new-architecture/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Sentry.init({
4141
idleTimeout: 5000,
4242
routingInstrumentation: reactNavigationInstrumentation,
4343
tracingOrigins: ['localhost', /^\//, /^https:\/\//],
44+
enableUserInteractionTracing: true,
4445
beforeNavigate: (context: Sentry.ReactNavigationTransactionContext) => {
4546
// Example of not sending a transaction for the screen with the name "Manual Tracker"
4647
if (context.data.route.name === 'ManualTracker') {

sample-new-architecture/src/Screens/TrackerScreen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const TrackerScreen = () => {
7171
<ActivityIndicator size="small" color="#F6F6F8" />
7272
)}
7373
</View>
74-
<Button title="Refresh" onPress={loadData} />
74+
<Button sentry-label="refresh" title="Refresh" onPress={loadData} />
7575
</View>
7676
);
7777
};

src/js/touchevents.tsx

Lines changed: 79 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import * as React from 'react';
55
import { StyleSheet, View } from 'react-native';
66

77
import { createIntegration } from './integrations/factory';
8+
import { ReactNativeTracing } from './tracing';
9+
import { ACTION_TOUCH_OP } from './tracing/operations';
810

911
export type TouchEventBoundaryProps = {
1012
/**
@@ -49,7 +51,7 @@ const DEFAULT_BREADCRUMB_CATEGORY = 'touch';
4951
const DEFAULT_BREADCRUMB_TYPE = 'user';
5052
const DEFAULT_MAX_COMPONENT_TREE_SIZE = 20;
5153

52-
const PROP_KEY = 'sentry-label';
54+
const SENTRY_LABEL_PROP_KEY = 'sentry-label';
5355

5456
interface ElementInstance {
5557
elementType?: {
@@ -64,6 +66,7 @@ interface ElementInstance {
6466
* Boundary to log breadcrumbs for interaction events.
6567
*/
6668
class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
69+
6770
public static displayName: string = '__Sentry.TouchEventBoundary';
6871
public static defaultProps: Partial<TouchEventBoundaryProps> = {
6972
breadcrumbCategory: DEFAULT_BREADCRUMB_CATEGORY,
@@ -74,11 +77,17 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
7477

7578
public readonly name: string = 'TouchEventBoundary';
7679

80+
private _tracingIntegration: ReactNativeTracing | null = null;
81+
7782
/**
7883
* Registers the TouchEventBoundary as a Sentry Integration.
7984
*/
8085
public componentDidMount(): void {
81-
getCurrentHub().getClient()?.addIntegration?.(createIntegration(this.name));
86+
const client = getCurrentHub().getClient();
87+
client?.addIntegration?.(createIntegration(this.name));
88+
if (!this._tracingIntegration && client) {
89+
this._tracingIntegration = client.getIntegration(ReactNativeTracing);
90+
}
8291
}
8392

8493
/**
@@ -147,77 +156,84 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
147156
*/
148157
// eslint-disable-next-line complexity
149158
private _onTouchStart(e: { _targetInst?: ElementInstance }): void {
150-
if (e._targetInst) {
151-
let currentInst: ElementInstance | undefined = e._targetInst;
152-
153-
let activeLabel: string | undefined;
154-
let activeDisplayName: string | undefined;
155-
const componentTreeNames: string[] = [];
156-
157-
while (
158-
currentInst &&
159-
// maxComponentTreeSize will always be defined as we have a defaultProps. But ts needs a check so this is here.
160-
this.props.maxComponentTreeSize &&
161-
componentTreeNames.length < this.props.maxComponentTreeSize
159+
if (!e._targetInst) {
160+
return;
161+
}
162+
163+
let currentInst: ElementInstance | undefined = e._targetInst;
164+
165+
let activeLabel: string | undefined;
166+
let activeDisplayName: string | undefined;
167+
const componentTreeNames: string[] = [];
168+
169+
while (
170+
currentInst &&
171+
// maxComponentTreeSize will always be defined as we have a defaultProps. But ts needs a check so this is here.
172+
this.props.maxComponentTreeSize &&
173+
componentTreeNames.length < this.props.maxComponentTreeSize
174+
) {
175+
if (
176+
// If the loop gets to the boundary itself, break.
177+
currentInst.elementType?.displayName ===
178+
TouchEventBoundary.displayName
162179
) {
163-
if (
164-
// If the loop gets to the boundary itself, break.
165-
currentInst.elementType?.displayName ===
166-
TouchEventBoundary.displayName
167-
) {
168-
break;
180+
break;
181+
}
182+
183+
const props = currentInst.memoizedProps;
184+
const sentryLabel =
185+
typeof props?.[SENTRY_LABEL_PROP_KEY] !== 'undefined'
186+
? `${props[SENTRY_LABEL_PROP_KEY]}`
187+
: undefined;
188+
189+
// For some reason type narrowing doesn't work as expected with indexing when checking it all in one go in
190+
// the "check-label" if sentence, so we have to assign it to a variable here first
191+
let labelValue;
192+
if (typeof this.props.labelName === 'string')
193+
labelValue = props?.[this.props.labelName];
194+
195+
// Check the label first
196+
if (sentryLabel && !this._isNameIgnored(sentryLabel)) {
197+
if (!activeLabel) {
198+
activeLabel = sentryLabel;
199+
}
200+
componentTreeNames.push(sentryLabel);
201+
} else if (
202+
typeof labelValue === 'string' &&
203+
!this._isNameIgnored(labelValue)
204+
) {
205+
if (!activeLabel) {
206+
activeLabel = labelValue;
169207
}
208+
componentTreeNames.push(labelValue);
209+
} else if (currentInst.elementType) {
210+
const { elementType } = currentInst;
170211

171-
const props = currentInst.memoizedProps;
172-
const label =
173-
typeof props?.[PROP_KEY] !== 'undefined'
174-
? `${props[PROP_KEY]}`
175-
: undefined;
176-
177-
// For some reason type narrowing doesn't work as expected with indexing when checking it all in one go in
178-
// the "check-label" if sentence, so we have to assign it to a variable here first
179-
let labelValue;
180-
if (typeof this.props.labelName === 'string')
181-
labelValue = props?.[this.props.labelName];
182-
183-
// Check the label first
184-
if (label && !this._isNameIgnored(label)) {
185-
if (!activeLabel) {
186-
activeLabel = label;
187-
}
188-
componentTreeNames.push(label);
189-
} else if (
190-
typeof labelValue === 'string' &&
191-
!this._isNameIgnored(labelValue)
212+
if (
213+
elementType.displayName &&
214+
!this._isNameIgnored(elementType.displayName)
192215
) {
193-
if (!activeLabel) {
194-
activeLabel = labelValue;
195-
}
196-
componentTreeNames.push(labelValue);
197-
} else if (currentInst.elementType) {
198-
const { elementType } = currentInst;
199-
200-
if (
201-
elementType.displayName &&
202-
!this._isNameIgnored(elementType.displayName)
203-
) {
204-
// Check display name
205-
if (!activeDisplayName) {
206-
activeDisplayName = elementType.displayName;
207-
}
208-
componentTreeNames.push(elementType.displayName);
216+
// Check display name
217+
if (!activeDisplayName) {
218+
activeDisplayName = elementType.displayName;
209219
}
220+
componentTreeNames.push(elementType.displayName);
210221
}
211-
212-
currentInst = currentInst.return;
213222
}
214223

215-
const finalLabel = activeLabel ?? activeDisplayName;
224+
currentInst = currentInst.return;
225+
}
216226

217-
if (componentTreeNames.length > 0 || finalLabel) {
218-
this._logTouchEvent(componentTreeNames, finalLabel);
219-
}
227+
const finalLabel = activeLabel ?? activeDisplayName;
228+
229+
if (componentTreeNames.length > 0 || finalLabel) {
230+
this._logTouchEvent(componentTreeNames, finalLabel);
220231
}
232+
233+
this._tracingIntegration?.startUserInteractionTransaction({
234+
elementId: activeLabel,
235+
op: ACTION_TOUCH_OP,
236+
});
221237
}
222238
}
223239

src/js/tracing/operations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
2+
export const ACTION_TOUCH_OP = 'ui.action.touch';

src/js/tracing/reactnativetracing.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
/* eslint-disable max-lines */
22
import type { Hub } from '@sentry/core';
3+
import { getCurrentHub } from '@sentry/core';
34
import type {
45
IdleTransaction,
56
RequestInstrumentationOptions,
67
Transaction
78
} from '@sentry/tracing';
89
import {
910
defaultRequestInstrumentationOptions,
11+
getActiveTransaction
12+
,
1013
instrumentOutgoingRequests,
1114
startIdleTransaction
1215
} from '@sentry/tracing';
@@ -29,6 +32,9 @@ import {
2932
UI_LOAD,
3033
} from './ops';
3134
import { StallTrackingInstrumentation } from './stalltracking';
35+
import {
36+
onlySampleIfChildSpans,
37+
} from './transaction';
3238
import type { BeforeNavigate, RouteChangeContextData } from './types';
3339
import {
3440
adjustTransactionDuration,
@@ -108,6 +114,8 @@ export interface ReactNativeTracingOptions
108114
* Track when and how long the JS event loop stalls for. Adds stalls as measurements to all transactions.
109115
*/
110116
enableStallTracking: boolean;
117+
118+
enableUserInteractionTracing: boolean;
111119
}
112120

113121
const defaultReactNativeTracingOptions: ReactNativeTracingOptions = {
@@ -121,6 +129,7 @@ const defaultReactNativeTracingOptions: ReactNativeTracingOptions = {
121129
enableAppStartTracking: true,
122130
enableNativeFramesTracking: true,
123131
enableStallTracking: true,
132+
enableUserInteractionTracing: false,
124133
};
125134

126135
/**
@@ -145,9 +154,11 @@ export class ReactNativeTracing implements Integration {
145154
public stallTrackingInstrumentation?: StallTrackingInstrumentation;
146155
public useAppStartWithProfiler: boolean = false;
147156

157+
private _inflightInteractionTransaction?: IdleTransaction;
148158
private _getCurrentHub?: () => Hub;
149159
private _awaitingAppStartData?: NativeAppStartResponse;
150160
private _appStartFinishTimestamp?: number;
161+
private _currentRoute?: string;
151162

152163
public constructor(options: Partial<ReactNativeTracingOptions> = {}) {
153164
this.options = {
@@ -271,6 +282,71 @@ export class ReactNativeTracing implements Integration {
271282
this._appStartFinishTimestamp = endTimestamp;
272283
}
273284

285+
/**
286+
* Starts a new transaction for a user interaction.
287+
* @param userInteractionId Consists of `op` representation UI Event and `elementId` unique element identifier on current screen.
288+
*/
289+
public startUserInteractionTransaction(userInteractionId: {
290+
elementId: string | undefined;
291+
op: string;
292+
}): TransactionType | undefined {
293+
const { elementId, op } = userInteractionId;
294+
if (!this.options.enableUserInteractionTracing) {
295+
logger.log('[ReactNativeTracing] User Interaction Tracing is disabled.');
296+
return;
297+
}
298+
if (!this.options.routingInstrumentation) {
299+
logger.error('[ReactNativeTracing] User Interaction Tracing is not working because no routing instrumentation is set.');
300+
return;
301+
}
302+
if (!elementId) {
303+
logger.log('[ReactNativeTracing] User Interaction Tracing can not create transaction with undefined elementId.');
304+
return;
305+
}
306+
if (!this._currentRoute) {
307+
logger.log('[ReactNativeTracing] User Interaction Tracing can not create transaction without a current route.');
308+
return;
309+
}
310+
311+
const hub = this._getCurrentHub?.() || getCurrentHub();
312+
const activeTransaction = getActiveTransaction(hub);
313+
const activeTransactionIsNotInteraction =
314+
activeTransaction?.spanId !== this._inflightInteractionTransaction?.spanId;
315+
if (activeTransaction && activeTransactionIsNotInteraction) {
316+
logger.warn(`[ReactNativeTracing] Did not create ${op} transaction because active transaction ${activeTransaction.name} exists on the scope.`);
317+
return;
318+
}
319+
320+
const { idleTimeoutMs, finalTimeoutMs } = this.options;
321+
322+
if (this._inflightInteractionTransaction) {
323+
this._inflightInteractionTransaction = undefined;
324+
// TODO: cancel the idle timeout
325+
}
326+
327+
const name = `${this._currentRoute}.${elementId}`;
328+
const context: TransactionContext = {
329+
name,
330+
op,
331+
trimEnd: true,
332+
};
333+
this._inflightInteractionTransaction = startIdleTransaction(
334+
hub,
335+
context,
336+
idleTimeoutMs,
337+
finalTimeoutMs,
338+
true,
339+
);
340+
this._inflightInteractionTransaction.registerBeforeFinishCallback((transaction: IdleTransaction) => {
341+
this._inflightInteractionTransaction = undefined;
342+
this.onTransactionFinish(transaction);
343+
});
344+
this._inflightInteractionTransaction.registerBeforeFinishCallback(onlySampleIfChildSpans);
345+
this.onTransactionStart(this._inflightInteractionTransaction);
346+
logger.log(`[ReactNativeTracing] User Interaction Tracing Created ${op} transaction ${name}.`);
347+
return this._inflightInteractionTransaction;
348+
}
349+
274350
/**
275351
* Instruments the app start measurements on the first route transaction.
276352
* Starts a route transaction if there isn't routing instrumentation.
@@ -354,6 +430,9 @@ export class ReactNativeTracing implements Integration {
354430
* Creates a breadcrumb and sets the current route as a tag.
355431
*/
356432
private _onConfirmRoute(context: TransactionContext): void {
433+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
434+
this._currentRoute = context.data?.route?.name;
435+
357436
this._getCurrentHub?.().configureScope((scope) => {
358437
if (context.data) {
359438
const contextData = context.data as RouteChangeContextData;
@@ -385,6 +464,14 @@ export class ReactNativeTracing implements Integration {
385464
return undefined;
386465
}
387466

467+
if (this._inflightInteractionTransaction) {
468+
logger.log(
469+
`[ReactNativeTracing] Canceling ${this._inflightInteractionTransaction.op} transaction because navigation ${context.op}.`
470+
);
471+
this._inflightInteractionTransaction.setStatus('cancelled');
472+
this._inflightInteractionTransaction.finish();
473+
}
474+
388475
// eslint-disable-next-line @typescript-eslint/unbound-method
389476
const { idleTimeoutMs, finalTimeoutMs } = this.options;
390477

src/js/tracing/transaction.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { IdleTransaction } from '@sentry/tracing';
2+
import type { BeforeFinishCallback } from '@sentry/tracing/types/idletransaction';
3+
import { logger } from '@sentry/utils';
4+
5+
/**
6+
* Idle Transaction callback to only sample transactions with child spans.
7+
* To avoid side effects of other callbacks this should be hooked as the last callback.
8+
*/
9+
export const onlySampleIfChildSpans: BeforeFinishCallback = (
10+
transaction: IdleTransaction,
11+
): void => {
12+
const spansCount = transaction.spanRecorder && transaction.spanRecorder.spans.filter(
13+
(span) => span.spanId !== transaction.spanId
14+
).length;
15+
16+
if (!spansCount || spansCount <= 0) {
17+
logger.log(`Not sampling as ${transaction.op} transaction has no child spans.`);
18+
transaction.sampled = false;
19+
}
20+
}

0 commit comments

Comments
 (0)