Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
901f8c3
WIP! Add UI Tracing Element Touch Transactions
krystofwoldrich Feb 17, 2023
32db170
WIP! Add tests
krystofwoldrich Feb 20, 2023
46f6ce6
Add more test and fix same event and same element behavior
krystofwoldrich Feb 20, 2023
eac4d01
Add different ui event and same element
krystofwoldrich Feb 21, 2023
0553f84
Add transactions sequence, scope and routing tests
krystofwoldrich Feb 21, 2023
c27ba3b
Fix lint
krystofwoldrich Feb 21, 2023
e39c56c
Add lifecycle hooks calls to ui event transactions
krystofwoldrich Feb 21, 2023
88341e9
Add JSDoc
krystofwoldrich Feb 21, 2023
81592e4
Merge remote-tracking branch 'origin/main' into kw-interaction-tracing
krystofwoldrich Feb 23, 2023
ed50cf4
Merge remote-tracking branch 'origin/main' into kw-interaction-tracing
krystofwoldrich Feb 28, 2023
c5de70c
Add changelog
krystofwoldrich Feb 28, 2023
a4cf108
Fix lint
krystofwoldrich Feb 28, 2023
3025015
Update same event same element behavior
krystofwoldrich Mar 3, 2023
170822a
Merge branch 'main' into kw-interaction-tracing
krystofwoldrich Mar 3, 2023
d69e641
Merge remote-tracking branch 'origin/main' into kw-interaction-tracing
krystofwoldrich Mar 7, 2023
1495866
Add cancelIdleTimeout
krystofwoldrich Mar 7, 2023
b31dcac
Update changelog
krystofwoldrich Mar 7, 2023
63666d8
remove duplicate ops files
krystofwoldrich Mar 9, 2023
3d77eab
fix import
krystofwoldrich Mar 9, 2023
3d05093
fix op naming
krystofwoldrich Mar 9, 2023
097c108
Merge branch 'main' into kw-interaction-tracing
krystofwoldrich Mar 15, 2023
915bc1a
Update before finish callback types
krystofwoldrich Mar 15, 2023
6191530
Merge branch 'main' into kw-interaction-tracing
krystofwoldrich Mar 20, 2023
b15cb59
fix lint imports
krystofwoldrich Mar 20, 2023
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- Add User Interaction Tracing for Touch events ([#2835](https://github.com/getsentry/sentry-react-native/pull/2835))

### Fixes

- Fix use Fetch transport when option `enableNative` is `false` ([#2897](https://github.com/getsentry/sentry-react-native/pull/2897))
Expand Down
1 change: 1 addition & 0 deletions sample-new-architecture/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Sentry.init({
idleTimeout: 5000,
routingInstrumentation: reactNavigationInstrumentation,
tracingOrigins: ['localhost', /^\//, /^https:\/\//],
enableUserInteractionTracing: true,
beforeNavigate: (context: Sentry.ReactNavigationTransactionContext) => {
// Example of not sending a transaction for the screen with the name "Manual Tracker"
if (context.data.route.name === 'ManualTracker') {
Expand Down
2 changes: 1 addition & 1 deletion sample-new-architecture/src/Screens/TrackerScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const TrackerScreen = () => {
<ActivityIndicator size="small" color="#F6F6F8" />
)}
</View>
<Button title="Refresh" onPress={loadData} />
<Button sentry-label="refresh" title="Refresh" onPress={loadData} />
</View>
);
};
Expand Down
142 changes: 79 additions & 63 deletions src/js/touchevents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import * as React from 'react';
import { StyleSheet, View } from 'react-native';

import { createIntegration } from './integrations/factory';
import { ReactNativeTracing } from './tracing';
import { UI_ACTION_TOUCH } from './tracing/ops';

export type TouchEventBoundaryProps = {
/**
Expand Down Expand Up @@ -49,7 +51,7 @@ const DEFAULT_BREADCRUMB_CATEGORY = 'touch';
const DEFAULT_BREADCRUMB_TYPE = 'user';
const DEFAULT_MAX_COMPONENT_TREE_SIZE = 20;

const PROP_KEY = 'sentry-label';
const SENTRY_LABEL_PROP_KEY = 'sentry-label';

interface ElementInstance {
elementType?: {
Expand All @@ -64,6 +66,7 @@ interface ElementInstance {
* Boundary to log breadcrumbs for interaction events.
*/
class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {

public static displayName: string = '__Sentry.TouchEventBoundary';
public static defaultProps: Partial<TouchEventBoundaryProps> = {
breadcrumbCategory: DEFAULT_BREADCRUMB_CATEGORY,
Expand All @@ -74,11 +77,17 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {

public readonly name: string = 'TouchEventBoundary';

private _tracingIntegration: ReactNativeTracing | null = null;

/**
* Registers the TouchEventBoundary as a Sentry Integration.
*/
public componentDidMount(): void {
getCurrentHub().getClient()?.addIntegration?.(createIntegration(this.name));
const client = getCurrentHub().getClient();
client?.addIntegration?.(createIntegration(this.name));
if (!this._tracingIntegration && client) {
this._tracingIntegration = client.getIntegration(ReactNativeTracing);
}
}

/**
Expand Down Expand Up @@ -147,77 +156,84 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
*/
// eslint-disable-next-line complexity
private _onTouchStart(e: { _targetInst?: ElementInstance }): void {
if (e._targetInst) {
let currentInst: ElementInstance | undefined = e._targetInst;

let activeLabel: string | undefined;
let activeDisplayName: string | undefined;
const componentTreeNames: string[] = [];

while (
currentInst &&
// maxComponentTreeSize will always be defined as we have a defaultProps. But ts needs a check so this is here.
this.props.maxComponentTreeSize &&
componentTreeNames.length < this.props.maxComponentTreeSize
if (!e._targetInst) {
return;
}

let currentInst: ElementInstance | undefined = e._targetInst;

let activeLabel: string | undefined;
let activeDisplayName: string | undefined;
const componentTreeNames: string[] = [];

while (
currentInst &&
// maxComponentTreeSize will always be defined as we have a defaultProps. But ts needs a check so this is here.
this.props.maxComponentTreeSize &&
componentTreeNames.length < this.props.maxComponentTreeSize
) {
if (
// If the loop gets to the boundary itself, break.
currentInst.elementType?.displayName ===
TouchEventBoundary.displayName
) {
if (
// If the loop gets to the boundary itself, break.
currentInst.elementType?.displayName ===
TouchEventBoundary.displayName
) {
break;
break;
}

const props = currentInst.memoizedProps;
const sentryLabel =
typeof props?.[SENTRY_LABEL_PROP_KEY] !== 'undefined'
? `${props[SENTRY_LABEL_PROP_KEY]}`
: undefined;

// For some reason type narrowing doesn't work as expected with indexing when checking it all in one go in
// the "check-label" if sentence, so we have to assign it to a variable here first
let labelValue;
if (typeof this.props.labelName === 'string')
labelValue = props?.[this.props.labelName];

// Check the label first
if (sentryLabel && !this._isNameIgnored(sentryLabel)) {
if (!activeLabel) {
activeLabel = sentryLabel;
}
componentTreeNames.push(sentryLabel);
} else if (
typeof labelValue === 'string' &&
!this._isNameIgnored(labelValue)
) {
if (!activeLabel) {
activeLabel = labelValue;
}
componentTreeNames.push(labelValue);
} else if (currentInst.elementType) {
const { elementType } = currentInst;

const props = currentInst.memoizedProps;
const label =
typeof props?.[PROP_KEY] !== 'undefined'
? `${props[PROP_KEY]}`
: undefined;

// For some reason type narrowing doesn't work as expected with indexing when checking it all in one go in
// the "check-label" if sentence, so we have to assign it to a variable here first
let labelValue;
if (typeof this.props.labelName === 'string')
labelValue = props?.[this.props.labelName];

// Check the label first
if (label && !this._isNameIgnored(label)) {
if (!activeLabel) {
activeLabel = label;
}
componentTreeNames.push(label);
} else if (
typeof labelValue === 'string' &&
!this._isNameIgnored(labelValue)
if (
elementType.displayName &&
!this._isNameIgnored(elementType.displayName)
) {
if (!activeLabel) {
activeLabel = labelValue;
}
componentTreeNames.push(labelValue);
} else if (currentInst.elementType) {
const { elementType } = currentInst;

if (
elementType.displayName &&
!this._isNameIgnored(elementType.displayName)
) {
// Check display name
if (!activeDisplayName) {
activeDisplayName = elementType.displayName;
}
componentTreeNames.push(elementType.displayName);
// Check display name
if (!activeDisplayName) {
activeDisplayName = elementType.displayName;
}
componentTreeNames.push(elementType.displayName);
}

currentInst = currentInst.return;
}

const finalLabel = activeLabel ?? activeDisplayName;
currentInst = currentInst.return;
}

if (componentTreeNames.length > 0 || finalLabel) {
this._logTouchEvent(componentTreeNames, finalLabel);
}
const finalLabel = activeLabel ?? activeDisplayName;

if (componentTreeNames.length > 0 || finalLabel) {
this._logTouchEvent(componentTreeNames, finalLabel);
}

this._tracingIntegration?.startUserInteractionTransaction({
elementId: activeLabel,
op: UI_ACTION_TOUCH,
});
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/js/tracing/ops.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@

export const DEFAULT = 'default';
export const UI_LOAD = 'ui.load';
export const NAVIGATION = 'navigation';

export const UI_LOAD = 'ui.load';
export const UI_ACTION_TOUCH = 'ui.action.touch';

export const APP_START_COLD = 'app.start.cold';
export const APP_START_WARM = 'app.start.warm';
90 changes: 90 additions & 0 deletions src/js/tracing/reactnativetracing.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
/* eslint-disable max-lines */
import type { Hub } from '@sentry/core';
import { getCurrentHub } from '@sentry/core';
import type {
IdleTransaction,
RequestInstrumentationOptions,
Transaction
} from '@sentry/tracing';
import {
defaultRequestInstrumentationOptions,
getActiveTransaction
,
instrumentOutgoingRequests,
startIdleTransaction
} from '@sentry/tracing';
Expand All @@ -29,6 +32,9 @@ import {
UI_LOAD,
} from './ops';
import { StallTrackingInstrumentation } from './stalltracking';
import {
onlySampleIfChildSpans,
} from './transaction';
import type { BeforeNavigate, RouteChangeContextData } from './types';
import {
adjustTransactionDuration,
Expand Down Expand Up @@ -108,6 +114,11 @@ export interface ReactNativeTracingOptions
* Track when and how long the JS event loop stalls for. Adds stalls as measurements to all transactions.
*/
enableStallTracking: boolean;

/**
* Trace User Interaction events like touch and gestures.
*/
enableUserInteractionTracing: boolean;
}

const defaultReactNativeTracingOptions: ReactNativeTracingOptions = {
Expand All @@ -121,6 +132,7 @@ const defaultReactNativeTracingOptions: ReactNativeTracingOptions = {
enableAppStartTracking: true,
enableNativeFramesTracking: true,
enableStallTracking: true,
enableUserInteractionTracing: false,
};

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

private _inflightInteractionTransaction?: IdleTransaction;
private _getCurrentHub?: () => Hub;
private _awaitingAppStartData?: NativeAppStartResponse;
private _appStartFinishTimestamp?: number;
private _currentRoute?: string;

public constructor(options: Partial<ReactNativeTracingOptions> = {}) {
this.options = {
Expand Down Expand Up @@ -271,6 +285,71 @@ export class ReactNativeTracing implements Integration {
this._appStartFinishTimestamp = endTimestamp;
}

/**
* Starts a new transaction for a user interaction.
* @param userInteractionId Consists of `op` representation UI Event and `elementId` unique element identifier on current screen.
*/
public startUserInteractionTransaction(userInteractionId: {
elementId: string | undefined;
op: string;
}): TransactionType | undefined {
const { elementId, op } = userInteractionId;
if (!this.options.enableUserInteractionTracing) {
logger.log('[ReactNativeTracing] User Interaction Tracing is disabled.');
return;
}
if (!this.options.routingInstrumentation) {
logger.error('[ReactNativeTracing] User Interaction Tracing is not working because no routing instrumentation is set.');
return;
}
if (!elementId) {
logger.log('[ReactNativeTracing] User Interaction Tracing can not create transaction with undefined elementId.');
return;
}
if (!this._currentRoute) {
logger.log('[ReactNativeTracing] User Interaction Tracing can not create transaction without a current route.');
return;
}

const hub = this._getCurrentHub?.() || getCurrentHub();
const activeTransaction = getActiveTransaction(hub);
const activeTransactionIsNotInteraction =
activeTransaction?.spanId !== this._inflightInteractionTransaction?.spanId;
if (activeTransaction && activeTransactionIsNotInteraction) {
logger.warn(`[ReactNativeTracing] Did not create ${op} transaction because active transaction ${activeTransaction.name} exists on the scope.`);
return;
}

const { idleTimeoutMs, finalTimeoutMs } = this.options;

if (this._inflightInteractionTransaction) {
this._inflightInteractionTransaction.cancelIdleTimeout(undefined, { restartOnChildSpanChange: false });
this._inflightInteractionTransaction = undefined;
}

const name = `${this._currentRoute}.${elementId}`;
const context: TransactionContext = {
name,
op,
trimEnd: true,
};
this._inflightInteractionTransaction = startIdleTransaction(
hub,
context,
idleTimeoutMs,
finalTimeoutMs,
true,
);
this._inflightInteractionTransaction.registerBeforeFinishCallback((transaction: IdleTransaction) => {
this._inflightInteractionTransaction = undefined;
this.onTransactionFinish(transaction);
});
this._inflightInteractionTransaction.registerBeforeFinishCallback(onlySampleIfChildSpans);
this.onTransactionStart(this._inflightInteractionTransaction);
logger.log(`[ReactNativeTracing] User Interaction Tracing Created ${op} transaction ${name}.`);
return this._inflightInteractionTransaction;
}

/**
* Instruments the app start measurements on the first route transaction.
* Starts a route transaction if there isn't routing instrumentation.
Expand Down Expand Up @@ -354,6 +433,9 @@ export class ReactNativeTracing implements Integration {
* Creates a breadcrumb and sets the current route as a tag.
*/
private _onConfirmRoute(context: TransactionContext): void {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
this._currentRoute = context.data?.route?.name;

this._getCurrentHub?.().configureScope((scope) => {
if (context.data) {
const contextData = context.data as RouteChangeContextData;
Expand Down Expand Up @@ -385,6 +467,14 @@ export class ReactNativeTracing implements Integration {
return undefined;
}

if (this._inflightInteractionTransaction) {
logger.log(
`[ReactNativeTracing] Canceling ${this._inflightInteractionTransaction.op} transaction because navigation ${context.op}.`
);
this._inflightInteractionTransaction.setStatus('cancelled');
this._inflightInteractionTransaction.finish();
}

// eslint-disable-next-line @typescript-eslint/unbound-method
const { idleTimeoutMs, finalTimeoutMs } = this.options;

Expand Down
Loading