Skip to content

Commit 97efbbb

Browse files
feat(sdk): UI Interaction Tracing (#2835)
1 parent f641dba commit 97efbbb

File tree

9 files changed

+452
-73
lines changed

9 files changed

+452
-73
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- Add User Interaction Tracing for Touch events ([#2835](https://github.com/getsentry/sentry-react-native/pull/2835))
8+
59
### Fixes
610

711
- Fix use Fetch transport when option `enableNative` is `false` ([#2897](https://github.com/getsentry/sentry-react-native/pull/2897))

sample-new-architecture/src/App.tsx

+1
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

+1-1
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

+79-63
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 { UI_ACTION_TOUCH } from './tracing/ops';
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: UI_ACTION_TOUCH,
236+
});
221237
}
222238
}
223239

src/js/tracing/ops.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11

22
export const DEFAULT = 'default';
3-
export const UI_LOAD = 'ui.load';
43
export const NAVIGATION = 'navigation';
54

5+
export const UI_LOAD = 'ui.load';
6+
export const UI_ACTION_TOUCH = 'ui.action.touch';
7+
68
export const APP_START_COLD = 'app.start.cold';
79
export const APP_START_WARM = 'app.start.warm';

src/js/tracing/reactnativetracing.ts

+90
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,11 @@ 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+
/**
119+
* Trace User Interaction events like touch and gestures.
120+
*/
121+
enableUserInteractionTracing: boolean;
111122
}
112123

113124
const defaultReactNativeTracingOptions: ReactNativeTracingOptions = {
@@ -121,6 +132,7 @@ const defaultReactNativeTracingOptions: ReactNativeTracingOptions = {
121132
enableAppStartTracking: true,
122133
enableNativeFramesTracking: true,
123134
enableStallTracking: true,
135+
enableUserInteractionTracing: false,
124136
};
125137

126138
/**
@@ -145,9 +157,11 @@ export class ReactNativeTracing implements Integration {
145157
public stallTrackingInstrumentation?: StallTrackingInstrumentation;
146158
public useAppStartWithProfiler: boolean = false;
147159

160+
private _inflightInteractionTransaction?: IdleTransaction;
148161
private _getCurrentHub?: () => Hub;
149162
private _awaitingAppStartData?: NativeAppStartResponse;
150163
private _appStartFinishTimestamp?: number;
164+
private _currentRoute?: string;
151165

152166
public constructor(options: Partial<ReactNativeTracingOptions> = {}) {
153167
this.options = {
@@ -271,6 +285,71 @@ export class ReactNativeTracing implements Integration {
271285
this._appStartFinishTimestamp = endTimestamp;
272286
}
273287

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

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

0 commit comments

Comments
 (0)