Skip to content

Commit 20b0c03

Browse files
fix(tracing): Cancel auto tx when app goes to background (#3307)
1 parent 6965bf8 commit 20b0c03

File tree

6 files changed

+150
-70
lines changed

6 files changed

+150
-70
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
- This flag equals `true` when Hermes Bundle contains Debug Info (Hermes Source Map was not emitted)
99
- Add `enableNdk` property to ReactNativeOptions for Android. ([#3304](https://github.com/getsentry/sentry-react-native/pull/3304))
1010

11+
### Fixes
12+
13+
- Cancel auto instrumentation transaction when app goes to background ([#3307])(https://github.com/getsentry/sentry-react-native/pull/3307)
14+
1115
## 5.9.2
1216

1317
### Fixes

sample-new-architecture/metro.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const config = {
2020
resolver: {
2121
blacklistRE: blacklist([
2222
new RegExp(`${parentDir}/node_modules/react-native/.*`),
23-
new RegExp(`.*\\android\\.*`), // Required for Windows in order to run the Sample.
23+
new RegExp('.*\\android\\.*'), // Required for Windows in order to run the Sample.
2424
]),
2525
extraNodeModules: new Proxy(
2626
{

sample-new-architecture/src/App.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ Sentry.init({
3535
console.log('Event beforeSend:', event.event_id);
3636
return event;
3737
},
38+
beforeSendTransaction(event) {
39+
console.log('Transaction beforeSend:', event.event_id);
40+
return event;
41+
},
3842
// This will be called with a boolean `didCallNativeInit` when the native SDK has been contacted.
3943
onReady: ({ didCallNativeInit }) => {
4044
console.log('onReady called with didCallNativeInit:', didCallNativeInit);

src/js/tracing/reactnativetracing.ts

+15-8
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { NATIVE } from '../wrapper';
1313
import { NativeFramesInstrumentation } from './nativeframes';
1414
import { APP_START_COLD as APP_START_COLD_OP, APP_START_WARM as APP_START_WARM_OP, UI_LOAD } from './ops';
1515
import { StallTrackingInstrumentation } from './stalltracking';
16-
import { onlySampleIfChildSpans } from './transaction';
16+
import { cancelInBackground, onlySampleIfChildSpans } from './transaction';
1717
import type { BeforeNavigate, RouteChangeContextData } from './types';
1818
import { adjustTransactionDuration, getTimeOriginMilliseconds, isNearToNow } from './utils';
1919

@@ -306,8 +306,6 @@ export class ReactNativeTracing implements Integration {
306306
return;
307307
}
308308

309-
const { idleTimeoutMs, finalTimeoutMs } = this.options;
310-
311309
if (this._inflightInteractionTransaction) {
312310
this._inflightInteractionTransaction.cancelIdleTimeout(undefined, { restartOnChildSpanChange: false });
313311
this._inflightInteractionTransaction = undefined;
@@ -319,7 +317,7 @@ export class ReactNativeTracing implements Integration {
319317
op,
320318
trimEnd: true,
321319
};
322-
this._inflightInteractionTransaction = startIdleTransaction(hub, context, idleTimeoutMs, finalTimeoutMs, true);
320+
this._inflightInteractionTransaction = this._startIdleTransaction(context);
323321
this._inflightInteractionTransaction.registerBeforeFinishCallback((transaction: IdleTransaction) => {
324322
this._inflightInteractionTransaction = undefined;
325323
this.onTransactionFinish(transaction);
@@ -445,16 +443,14 @@ export class ReactNativeTracing implements Integration {
445443
this._inflightInteractionTransaction.finish();
446444
}
447445

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

451448
const expandedContext = {
452449
...context,
453450
trimEnd: true,
454451
};
455452

456-
const hub = this._getCurrentHub();
457-
const idleTransaction = startIdleTransaction(hub as Hub, expandedContext, idleTimeoutMs, finalTimeoutMs, true);
453+
const idleTransaction = this._startIdleTransaction(expandedContext);
458454

459455
this.onTransactionStart(idleTransaction);
460456

@@ -498,4 +494,15 @@ export class ReactNativeTracing implements Integration {
498494

499495
return idleTransaction;
500496
}
497+
498+
/**
499+
* Start app state aware idle transaction on the scope.
500+
*/
501+
private _startIdleTransaction(context: TransactionContext): IdleTransaction {
502+
const { idleTimeoutMs, finalTimeoutMs } = this.options;
503+
const hub = this._getCurrentHub?.() || getCurrentHub();
504+
const tx = startIdleTransaction(hub, context, idleTimeoutMs, finalTimeoutMs, true);
505+
cancelInBackground(tx);
506+
return tx;
507+
}
501508
}

src/js/tracing/transaction.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import type { BeforeFinishCallback, IdleTransaction } from '@sentry/core';
1+
import { type BeforeFinishCallback, type IdleTransaction } from '@sentry/core';
22
import { logger } from '@sentry/utils';
3+
import type { AppStateStatus } from 'react-native';
4+
import { AppState } from 'react-native';
35

46
/**
57
* Idle Transaction callback to only sample transactions with child spans.
@@ -15,3 +17,20 @@ export const onlySampleIfChildSpans: BeforeFinishCallback = (transaction: IdleTr
1517
transaction.sampled = false;
1618
}
1719
};
20+
21+
/**
22+
* Hooks on AppState change to cancel the transaction if the app goes background.
23+
*/
24+
export const cancelInBackground = (transaction: IdleTransaction): void => {
25+
const subscription = AppState.addEventListener('change', (newState: AppStateStatus) => {
26+
if (newState === 'background') {
27+
logger.debug(`Setting ${transaction.op} transaction to cancelled because the app is in the background.`);
28+
transaction.setStatus('cancelled');
29+
transaction.finish();
30+
}
31+
});
32+
transaction.registerBeforeFinishCallback(() => {
33+
logger.debug(`Removing AppState listener for ${transaction.op} transaction.`);
34+
subscription.remove();
35+
});
36+
};

test/tracing/reactnativetracing.test.ts

+106-60
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,29 @@ jest.mock('../../src/js/tracing/utils', () => {
2828
};
2929
});
3030

31+
type MockAppState = {
32+
setState: (state: AppStateStatus) => void;
33+
listener: (newState: AppStateStatus) => void;
34+
removeSubscription: jest.Func;
35+
};
36+
const mockedAppState: AppState & MockAppState = {
37+
removeSubscription: jest.fn(),
38+
listener: jest.fn(),
39+
isAvailable: true,
40+
currentState: 'active',
41+
addEventListener: (_, listener) => {
42+
mockedAppState.listener = listener;
43+
return {
44+
remove: mockedAppState.removeSubscription,
45+
};
46+
},
47+
setState: (state: AppStateStatus) => {
48+
mockedAppState.currentState = state;
49+
mockedAppState.listener(state);
50+
},
51+
};
52+
jest.mock('react-native/Libraries/AppState/AppState', () => mockedAppState);
53+
3154
const getMockScope = () => {
3255
let scopeTransaction: Transaction | undefined;
3356
let scopeUser: User | undefined;
@@ -62,6 +85,7 @@ const getMockHub = () => {
6285

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

6690
import { APP_START_COLD, APP_START_WARM } from '../../src/js/measurements';
6791
import {
@@ -100,16 +124,7 @@ describe('ReactNativeTracing', () => {
100124
enableNativeFramesTracking: false,
101125
});
102126

103-
const timeOriginMilliseconds = Date.now();
104-
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
105-
const mockAppStartResponse: NativeAppStartResponse = {
106-
isColdStart: true,
107-
appStartTime: appStartTimeMilliseconds,
108-
didFetchAppStart: false,
109-
};
110-
111-
mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
112-
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
127+
const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: true });
113128

114129
const mockHub = getMockHub();
115130
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
@@ -139,16 +154,7 @@ describe('ReactNativeTracing', () => {
139154
it('Starts route transaction (warm)', async () => {
140155
const integration = new ReactNativeTracing();
141156

142-
const timeOriginMilliseconds = Date.now();
143-
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
144-
const mockAppStartResponse: NativeAppStartResponse = {
145-
isColdStart: false,
146-
appStartTime: appStartTimeMilliseconds,
147-
didFetchAppStart: false,
148-
};
149-
150-
mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
151-
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
157+
const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false });
152158

153159
const mockHub = getMockHub();
154160
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
@@ -173,6 +179,24 @@ describe('ReactNativeTracing', () => {
173179
}
174180
});
175181

182+
it('Cancels route transaction when app goes to background', async () => {
183+
const integration = new ReactNativeTracing();
184+
185+
mockAppStartResponse({ cold: false });
186+
187+
const mockHub = getMockHub();
188+
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
189+
190+
await jest.advanceTimersByTimeAsync(500);
191+
const transaction = mockHub.getScope()?.getTransaction();
192+
193+
mockedAppState.setState('background');
194+
jest.runAllTimers();
195+
196+
expect(transaction?.status).toBe('cancelled');
197+
expect(mockedAppState.removeSubscription).toBeCalledTimes(1);
198+
});
199+
176200
it('Does not add app start measurement if more than 60s', async () => {
177201
const integration = new ReactNativeTracing();
178202

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

215-
const timeOriginMilliseconds = Date.now();
216-
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
217-
const mockAppStartResponse: NativeAppStartResponse = {
218-
isColdStart: true,
219-
appStartTime: appStartTimeMilliseconds,
220-
didFetchAppStart: true,
221-
};
222-
223-
mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
224-
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
239+
mockAppStartResponse({ cold: false, didFetchAppStart: true });
225240

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

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

244-
const timeOriginMilliseconds = Date.now();
245-
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
246-
const mockAppStartResponse: NativeAppStartResponse = {
247-
isColdStart: true,
248-
appStartTime: appStartTimeMilliseconds,
249-
didFetchAppStart: false,
250-
};
259+
mockAppStartResponse({ cold: true });
251260

252-
mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
253-
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
261+
const mockHub = getMockHub();
262+
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
263+
// wait for internal promises to resolve, fetch app start data from mocked native
264+
await Promise.resolve();
265+
266+
const routeTransaction = routingInstrumentation.onRouteWillChange({
267+
name: 'test',
268+
}) as IdleTransaction;
269+
270+
mockedAppState.setState('background');
271+
272+
jest.runAllTimers();
273+
274+
expect(routeTransaction.status).toBe('cancelled');
275+
expect(mockedAppState.removeSubscription).toBeCalledTimes(1);
276+
});
277+
278+
it('Adds measurements and child span onto existing routing transaction and sets the op (cold)', async () => {
279+
const routingInstrumentation = new RoutingInstrumentation();
280+
const integration = new ReactNativeTracing({
281+
routingInstrumentation,
282+
});
283+
284+
const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: true });
254285

255286
const mockHub = getMockHub();
256287
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
@@ -297,16 +328,7 @@ describe('ReactNativeTracing', () => {
297328
routingInstrumentation,
298329
});
299330

300-
const timeOriginMilliseconds = Date.now();
301-
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
302-
const mockAppStartResponse: NativeAppStartResponse = {
303-
isColdStart: false,
304-
appStartTime: appStartTimeMilliseconds,
305-
didFetchAppStart: false,
306-
};
307-
308-
mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
309-
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
331+
const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false });
310332

311333
const mockHub = getMockHub();
312334
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
@@ -353,16 +375,7 @@ describe('ReactNativeTracing', () => {
353375
routingInstrumentation,
354376
});
355377

356-
const timeOriginMilliseconds = Date.now();
357-
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
358-
const mockAppStartResponse: NativeAppStartResponse = {
359-
isColdStart: false,
360-
appStartTime: appStartTimeMilliseconds,
361-
didFetchAppStart: true,
362-
};
363-
364-
mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
365-
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
378+
const [, appStartTimeMilliseconds] = mockAppStartResponse({ cold: false, didFetchAppStart: true });
366379

367380
const mockHub = getMockHub();
368381
integration.setupOnce(addGlobalEventProcessor, () => mockHub);
@@ -641,6 +654,24 @@ describe('ReactNativeTracing', () => {
641654
expect(actualTransactionContext?.sampled).toEqual(false);
642655
});
643656

657+
test('does cancel UI event transaction when app goes to background', () => {
658+
tracing.startUserInteractionTransaction(mockedUserInteractionId);
659+
660+
const actualTransaction = mockedScope.getTransaction() as Transaction | undefined;
661+
662+
mockedAppState.setState('background');
663+
jest.runAllTimers();
664+
665+
const actualTransactionContext = actualTransaction?.toContext();
666+
expect(actualTransactionContext).toEqual(
667+
expect.objectContaining({
668+
endTimestamp: expect.any(Number),
669+
status: 'cancelled',
670+
}),
671+
);
672+
expect(mockedAppState.removeSubscription).toBeCalledTimes(1);
673+
});
674+
644675
test('do not overwrite existing status of UI event transactions', () => {
645676
tracing.startUserInteractionTransaction(mockedUserInteractionId);
646677

@@ -792,3 +823,18 @@ describe('ReactNativeTracing', () => {
792823
});
793824
});
794825
});
826+
827+
function mockAppStartResponse({ cold, didFetchAppStart }: { cold: boolean; didFetchAppStart?: boolean }) {
828+
const timeOriginMilliseconds = Date.now();
829+
const appStartTimeMilliseconds = timeOriginMilliseconds - 100;
830+
const mockAppStartResponse: NativeAppStartResponse = {
831+
isColdStart: cold,
832+
appStartTime: appStartTimeMilliseconds,
833+
didFetchAppStart: didFetchAppStart ?? false,
834+
};
835+
836+
mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds);
837+
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse);
838+
839+
return [timeOriginMilliseconds, appStartTimeMilliseconds];
840+
}

0 commit comments

Comments
 (0)