Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
- This flag equals `true` when Hermes Bundle contains Debug Info (Hermes Source Map was not emitted)
- Add `enableNdk` property to ReactNativeOptions for Android. ([#3304](https://github.com/getsentry/sentry-react-native/pull/3304))

### Fixes

- Cancel auto instrumentation transaction when app goes to background ([#3306])(https://github.com/getsentry/sentry-react-native/pull/3306)

## 5.9.2

### Fixes
Expand Down
2 changes: 1 addition & 1 deletion sample-new-architecture/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const config = {
resolver: {
blacklistRE: blacklist([
new RegExp(`${parentDir}/node_modules/react-native/.*`),
new RegExp(`.*\\android\\.*`), // Required for Windows in order to run the Sample.
new RegExp('.*\\android\\.*'), // Required for Windows in order to run the Sample.
]),
extraNodeModules: new Proxy(
{
Expand Down
4 changes: 4 additions & 0 deletions sample-new-architecture/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ Sentry.init({
console.log('Event beforeSend:', event.event_id);
return event;
},
beforeSendTransaction(event) {
console.log('Transaction beforeSend:', event.event_id);
return event;
},
// This will be called with a boolean `didCallNativeInit` when the native SDK has been contacted.
onReady: ({ didCallNativeInit }) => {
console.log('onReady called with didCallNativeInit:', didCallNativeInit);
Expand Down
23 changes: 15 additions & 8 deletions src/js/tracing/reactnativetracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { NATIVE } from '../wrapper';
import { NativeFramesInstrumentation } from './nativeframes';
import { APP_START_COLD as APP_START_COLD_OP, APP_START_WARM as APP_START_WARM_OP, UI_LOAD } from './ops';
import { StallTrackingInstrumentation } from './stalltracking';
import { onlySampleIfChildSpans } from './transaction';
import { cancelInBackground, onlySampleIfChildSpans } from './transaction';
import type { BeforeNavigate, RouteChangeContextData } from './types';
import { adjustTransactionDuration, getTimeOriginMilliseconds, isNearToNow } from './utils';

Expand Down Expand Up @@ -306,8 +306,6 @@ export class ReactNativeTracing implements Integration {
return;
}

const { idleTimeoutMs, finalTimeoutMs } = this.options;

if (this._inflightInteractionTransaction) {
this._inflightInteractionTransaction.cancelIdleTimeout(undefined, { restartOnChildSpanChange: false });
this._inflightInteractionTransaction = undefined;
Expand All @@ -319,7 +317,7 @@ export class ReactNativeTracing implements Integration {
op,
trimEnd: true,
};
this._inflightInteractionTransaction = startIdleTransaction(hub, context, idleTimeoutMs, finalTimeoutMs, true);
this._inflightInteractionTransaction = this._startIdleTransaction(context);
this._inflightInteractionTransaction.registerBeforeFinishCallback((transaction: IdleTransaction) => {
this._inflightInteractionTransaction = undefined;
this.onTransactionFinish(transaction);
Expand Down Expand Up @@ -445,16 +443,14 @@ export class ReactNativeTracing implements Integration {
this._inflightInteractionTransaction.finish();
}

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

const expandedContext = {
...context,
trimEnd: true,
};

const hub = this._getCurrentHub();
const idleTransaction = startIdleTransaction(hub as Hub, expandedContext, idleTimeoutMs, finalTimeoutMs, true);
const idleTransaction = this._startIdleTransaction(expandedContext);

this.onTransactionStart(idleTransaction);

Expand Down Expand Up @@ -498,4 +494,15 @@ export class ReactNativeTracing implements Integration {

return idleTransaction;
}

/**
* Start app state aware idle transaction on the scope.
*/
private _startIdleTransaction(context: TransactionContext): IdleTransaction {
const { idleTimeoutMs, finalTimeoutMs } = this.options;
const hub = this._getCurrentHub?.() || getCurrentHub();
const tx = startIdleTransaction(hub, context, idleTimeoutMs, finalTimeoutMs, true);
cancelInBackground(tx);
return tx;
}
}
21 changes: 20 additions & 1 deletion src/js/tracing/transaction.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { BeforeFinishCallback, IdleTransaction } from '@sentry/core';
import { type BeforeFinishCallback, type IdleTransaction } from '@sentry/core';
import { logger } from '@sentry/utils';
import type { AppStateStatus } from 'react-native';
import { AppState } from 'react-native';

/**
* Idle Transaction callback to only sample transactions with child spans.
Expand All @@ -15,3 +17,20 @@ export const onlySampleIfChildSpans: BeforeFinishCallback = (transaction: IdleTr
transaction.sampled = false;
}
};

/**
* Hooks on AppState change to cancel the transaction if the app goes background.
*/
export const cancelInBackground = (transaction: IdleTransaction): void => {
const subscription = AppState.addEventListener('change', (newState: AppStateStatus) => {
if (newState === 'background') {
logger.debug(`Setting ${transaction.op} transaction to cancelled because the app is in the background.`);
transaction.setStatus('cancelled');
transaction.finish();
}
});
transaction.registerBeforeFinishCallback(() => {
logger.debug(`Removing AppState listener for ${transaction.op} transaction.`);
subscription.remove();
});
};
166 changes: 106 additions & 60 deletions test/tracing/reactnativetracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ jest.mock('../../src/js/tracing/utils', () => {
};
});

type MockAppState = {
setState: (state: AppStateStatus) => void;
listener: (newState: AppStateStatus) => void;
removeSubscription: jest.Func;
};
const mockedAppState: AppState & MockAppState = {
removeSubscription: jest.fn(),
listener: jest.fn(),
isAvailable: true,
currentState: 'active',
addEventListener: (_, listener) => {
mockedAppState.listener = listener;
return {
remove: mockedAppState.removeSubscription,
};
},
setState: (state: AppStateStatus) => {
mockedAppState.currentState = state;
mockedAppState.listener(state);
},
};
jest.mock('react-native/Libraries/AppState/AppState', () => mockedAppState);

const getMockScope = () => {
let scopeTransaction: Transaction | undefined;
let scopeUser: User | undefined;
Expand Down Expand Up @@ -62,6 +85,7 @@ const getMockHub = () => {

import type { BrowserClientOptions } from '@sentry/browser/types/client';
import type { Scope } from '@sentry/types';
import type { AppState, AppStateStatus } from 'react-native';

import { APP_START_COLD, APP_START_WARM } from '../../src/js/measurements';
import {
Expand Down Expand Up @@ -100,16 +124,7 @@ describe('ReactNativeTracing', () => {
enableNativeFramesTracking: false,
});

const timeOriginMilliseconds = Date.now();
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
const mockAppStartResponse: NativeAppStartResponse = {
isColdStart: true,
appStartTime: appStartTimeMilliseconds,
didFetchAppStart: false,
};

mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: true });

const mockHub = getMockHub();
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
Expand Down Expand Up @@ -139,16 +154,7 @@ describe('ReactNativeTracing', () => {
it('Starts route transaction (warm)', async () => {
const integration = new ReactNativeTracing();

const timeOriginMilliseconds = Date.now();
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
const mockAppStartResponse: NativeAppStartResponse = {
isColdStart: false,
appStartTime: appStartTimeMilliseconds,
didFetchAppStart: false,
};

mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false });

const mockHub = getMockHub();
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
Expand All @@ -173,6 +179,24 @@ describe('ReactNativeTracing', () => {
}
});

it('Cancels route transaction when app goes to background', async () => {
const integration = new ReactNativeTracing();

mockAppStartResponse({ cold: false });

const mockHub = getMockHub();
integration.setupOnce(addGlobalEventProcessor, () => mockHub);

await jest.advanceTimersByTimeAsync(500);
const transaction = mockHub.getScope()?.getTransaction();

mockedAppState.setState('background');
jest.runAllTimers();

expect(transaction?.status).toBe('cancelled');
expect(mockedAppState.removeSubscription).toBeCalledTimes(1);
});

it('Does not add app start measurement if more than 60s', async () => {
const integration = new ReactNativeTracing();

Expand Down Expand Up @@ -212,16 +236,7 @@ describe('ReactNativeTracing', () => {
it('Does not create app start transaction if didFetchAppStart == true', async () => {
const integration = new ReactNativeTracing();

const timeOriginMilliseconds = Date.now();
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
const mockAppStartResponse: NativeAppStartResponse = {
isColdStart: true,
appStartTime: appStartTimeMilliseconds,
didFetchAppStart: true,
};

mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
mockAppStartResponse({ cold: false, didFetchAppStart: true });

const mockHub = getMockHub();
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
Expand All @@ -235,22 +250,38 @@ describe('ReactNativeTracing', () => {
});

describe('With routing instrumentation', () => {
it('Adds measurements and child span onto existing routing transaction and sets the op (cold)', async () => {
it('Cancels route transaction when app goes to background', async () => {
const routingInstrumentation = new RoutingInstrumentation();
const integration = new ReactNativeTracing({
routingInstrumentation,
});

const timeOriginMilliseconds = Date.now();
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
const mockAppStartResponse: NativeAppStartResponse = {
isColdStart: true,
appStartTime: appStartTimeMilliseconds,
didFetchAppStart: false,
};
mockAppStartResponse({ cold: true });

mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
const mockHub = getMockHub();
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
// wait for internal promises to resolve, fetch app start data from mocked native
await Promise.resolve();

const routeTransaction = routingInstrumentation.onRouteWillChange({
name: 'test',
}) as IdleTransaction;

mockedAppState.setState('background');

jest.runAllTimers();

expect(routeTransaction.status).toBe('cancelled');
expect(mockedAppState.removeSubscription).toBeCalledTimes(1);
});

it('Adds measurements and child span onto existing routing transaction and sets the op (cold)', async () => {
const routingInstrumentation = new RoutingInstrumentation();
const integration = new ReactNativeTracing({
routingInstrumentation,
});

const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: true });

const mockHub = getMockHub();
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
Expand Down Expand Up @@ -297,16 +328,7 @@ describe('ReactNativeTracing', () => {
routingInstrumentation,
});

const timeOriginMilliseconds = Date.now();
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
const mockAppStartResponse: NativeAppStartResponse = {
isColdStart: false,
appStartTime: appStartTimeMilliseconds,
didFetchAppStart: false,
};

mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false });

const mockHub = getMockHub();
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
Expand Down Expand Up @@ -353,16 +375,7 @@ describe('ReactNativeTracing', () => {
routingInstrumentation,
});

const timeOriginMilliseconds = Date.now();
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
const mockAppStartResponse: NativeAppStartResponse = {
isColdStart: false,
appStartTime: appStartTimeMilliseconds,
didFetchAppStart: true,
};

mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
const [, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false, didFetchAppStart: true });

const mockHub = getMockHub();
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
Expand Down Expand Up @@ -641,6 +654,24 @@ describe('ReactNativeTracing', () => {
expect(actualTransactionContext?.sampled).toEqual(false);
});

test('does cancel UI event transaction when app goes to background', () => {
tracing.startUserInteractionTransaction(mockedUserInteractionId);

const actualTransaction = mockedScope.getTransaction() as Transaction | undefined;

mockedAppState.setState('background');
jest.runAllTimers();

const actualTransactionContext = actualTransaction?.toContext();
expect(actualTransactionContext).toEqual(
expect.objectContaining({
endTimestamp: expect.any(Number),
status: 'cancelled',
}),
);
expect(mockedAppState.removeSubscription).toBeCalledTimes(1);
});

test('do not overwrite existing status of UI event transactions', () => {
tracing.startUserInteractionTransaction(mockedUserInteractionId);

Expand Down Expand Up @@ -792,3 +823,18 @@ describe('ReactNativeTracing', () => {
});
});
});

function mockAppStartResponse({ cold, didFetchAppStart }: { cold: boolean; didFetchAppStart?: boolean }) {
const timeOriginMilliseconds = Date.now();
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
const mockAppStartResponse: NativeAppStartResponse = {
isColdStart: cold,
appStartTime: appStartTimeMilliseconds,
didFetchAppStart: didFetchAppStart ?? false,
};

mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);

return [timeOriginMilliseconds, appStartTimeMilliseconds];
}