Skip to content

Commit 4b535bf

Browse files
committed
fix(core): Defer standalone app start send to avoid duplicate transactions
When appLoaded() is called after auto-capture in standalone mode, two separate App Start transactions were sent to Sentry for the same launch. This inflates transaction counts and produces inconsistent app start metrics on the dashboard. Fix: in standalone mode, auto-capture from ReactNativeProfiler now defers the captureStandaloneAppStart() call via setTimeout(0) instead of sending immediately. This gives appLoaded() a chance to cancel the deferred send and replace it with a single transaction using the correct manual timestamp. If appLoaded() is never called, the deferred send fires on the next tick and the existing behavior is preserved — only one transaction is sent. Adds scheduleDeferredStandaloneCapture() and cancelDeferredStandaloneCapture() methods on the AppStartIntegration type. Adds a test verifying the deferred send fires when appLoaded() is not called.
1 parent 456fa3c commit 4b535bf

File tree

2 files changed

+94
-21
lines changed

2 files changed

+94
-21
lines changed

packages/core/src/js/tracing/integrations/appStart.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const INTEGRATION_NAME = 'AppStart';
3838
export type AppStartIntegration = Integration & {
3939
captureStandaloneAppStart: () => Promise<void>;
4040
resetAppStartDataFlushed: () => void;
41+
cancelDeferredStandaloneCapture: () => void;
42+
scheduleDeferredStandaloneCapture: () => void;
4143
};
4244

4345
/**
@@ -112,6 +114,9 @@ export async function _appLoaded(): Promise<void> {
112114

113115
const integration = client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME);
114116
if (integration) {
117+
// Cancel any deferred standalone send from auto-capture — we'll send our own
118+
// with the correct manual timestamp instead of sending two transactions.
119+
integration.cancelDeferredStandaloneCapture();
115120
// In standalone mode, auto-capture may have already flushed the transaction.
116121
// Reset the flag so captureStandaloneAppStart can re-send with the manual timestamp.
117122
integration.resetAppStartDataFlushed();
@@ -150,7 +155,19 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro
150155
});
151156

152157
await fetchAndUpdateEndFrames();
153-
await client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME)?.captureStandaloneAppStart();
158+
159+
const integration = client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME);
160+
if (integration) {
161+
if (!isManual) {
162+
// For auto-capture, defer the standalone send to give appLoaded() a chance
163+
// to override the end timestamp before the transaction is sent.
164+
// If appLoaded() is called, it cancels this deferred send and sends its own.
165+
// In non-standalone mode, scheduleDeferredStandaloneCapture is a no-op.
166+
integration.scheduleDeferredStandaloneCapture();
167+
} else {
168+
await integration.captureStandaloneAppStart();
169+
}
170+
}
154171
}
155172

156173
/**
@@ -276,6 +293,7 @@ export const appStartIntegration = ({
276293
let firstStartedActiveRootSpanId: string | undefined = undefined;
277294
let firstStartedActiveRootSpan: Span | undefined = undefined;
278295
let cachedNativeAppStart: NativeAppStartResponse | null | undefined = undefined;
296+
let deferredStandaloneTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
279297

280298
const setup = (client: Client): void => {
281299
_client = client;
@@ -305,6 +323,10 @@ export const appStartIntegration = ({
305323
firstStartedActiveRootSpan = undefined;
306324
isAppLoadedManuallyInvoked = false;
307325
cachedNativeAppStart = undefined;
326+
if (deferredStandaloneTimeout !== undefined) {
327+
clearTimeout(deferredStandaloneTimeout);
328+
deferredStandaloneTimeout = undefined;
329+
}
308330
} else {
309331
debug.log(
310332
'[AppStartIntegration] Waiting for initial app start was flush, before updating based on runApplication call.',
@@ -629,13 +651,34 @@ export const appStartIntegration = ({
629651
appStartDataFlushed = false;
630652
};
631653

654+
const cancelDeferredStandaloneCapture = (): void => {
655+
if (deferredStandaloneTimeout !== undefined) {
656+
clearTimeout(deferredStandaloneTimeout);
657+
deferredStandaloneTimeout = undefined;
658+
debug.log('[AppStart] Cancelled deferred standalone app start capture.');
659+
}
660+
};
661+
662+
const scheduleDeferredStandaloneCapture = (): void => {
663+
if (!standalone) {
664+
return;
665+
}
666+
deferredStandaloneTimeout = setTimeout(() => {
667+
deferredStandaloneTimeout = undefined;
668+
// oxlint-disable-next-line typescript-eslint(no-floating-promises)
669+
captureStandaloneAppStart();
670+
}, 0);
671+
};
672+
632673
return {
633674
name: INTEGRATION_NAME,
634675
setup,
635676
afterAllSetup,
636677
processEvent,
637678
captureStandaloneAppStart,
638679
resetAppStartDataFlushed,
680+
cancelDeferredStandaloneCapture,
681+
scheduleDeferredStandaloneCapture,
639682
setFirstStartedActiveRootSpanId,
640683
} as AppStartIntegration;
641684
};

packages/core/test/tracing/integrations/appStart.test.ts

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,43 +1260,73 @@ describe('appLoaded() standalone mode', () => {
12601260
integration.setup(standaloneClient);
12611261
standaloneClient.addIntegration(integration);
12621262

1263-
// Simulate auto-capture from ReactNativeProfiler (componentDidMount)
1263+
// Simulate auto-capture from ReactNativeProfiler (componentDidMount).
1264+
// In standalone mode, auto-capture defers the send to give appLoaded() a chance.
12641265
const autoTimeSeconds = Date.now() / 1000;
12651266
mockFunction(timestampInSeconds).mockReturnValue(autoTimeSeconds);
12661267
await _captureAppStart({ isManual: false });
1267-
await new Promise(resolve => setTimeout(resolve, 0));
1268-
1269-
// Auto-capture should have sent the standalone transaction
1270-
expect(standaloneClient.eventQueue.length).toBe(1);
1271-
const autoEvent = standaloneClient.eventQueue[0];
1272-
const autoSpan = autoEvent?.spans?.find(s => s.op === APP_START_COLD_OP);
1273-
expect(autoSpan?.timestamp).toBeCloseTo(autoTimeSeconds, 1);
12741268

1275-
// Simulate the native layer returning has_fetched: true after the first fetch.
1276-
// _appLoaded() must use the cached response to avoid bailing out.
1277-
mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({
1278-
type: 'cold' as const,
1279-
app_start_timestamp_ms: appStartTimeMilliseconds,
1280-
has_fetched: true,
1281-
spans: [],
1282-
});
1269+
// No transaction sent yet — the standalone send is deferred
1270+
expect(standaloneClient.eventQueue.length).toBe(0);
12831271

1284-
// Now call appLoaded() with a later timestamp
1272+
// Now call appLoaded() with a later timestamp — this cancels the deferred
1273+
// auto-capture send and sends only one transaction with the manual timestamp.
12851274
const manualTimeSeconds = autoTimeSeconds + 2;
12861275
mockFunction(timestampInSeconds).mockReturnValue(manualTimeSeconds);
12871276
await _appLoaded();
12881277
await new Promise(resolve => setTimeout(resolve, 0));
12891278

1290-
// appLoaded() should have sent a second standalone transaction with the manual timestamp
1291-
expect(standaloneClient.eventQueue.length).toBe(2);
1292-
const manualEvent = standaloneClient.eventQueue[1];
1279+
// Only one transaction should be sent the manual one
1280+
expect(standaloneClient.eventQueue.length).toBe(1);
1281+
const manualEvent = standaloneClient.eventQueue[0];
12931282
const manualSpan = manualEvent?.spans?.find(s => s.op === APP_START_COLD_OP);
12941283
expect(manualSpan).toBeDefined();
12951284
expect(manualSpan?.timestamp).toBeCloseTo(manualTimeSeconds, 1);
12961285
expect(manualSpan?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1);
12971286
expect(manualSpan?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START);
12981287
});
12991288

1289+
it('sends deferred standalone transaction when appLoaded() is not called', async () => {
1290+
jest.useFakeTimers();
1291+
1292+
getCurrentScope().clear();
1293+
getIsolationScope().clear();
1294+
getGlobalScope().clear();
1295+
1296+
const [, appStartTimeMilliseconds] = mockAppStart({ cold: true });
1297+
1298+
const integration = appStartIntegration({ standalone: true }) as AppStartIntegrationTest;
1299+
const standaloneClient = new TestClient({
1300+
...getDefaultTestClientOptions(),
1301+
enableAppStartTracking: true,
1302+
tracesSampleRate: 1.0,
1303+
});
1304+
setCurrentClient(standaloneClient);
1305+
integration.setup(standaloneClient);
1306+
standaloneClient.addIntegration(integration);
1307+
1308+
const autoTimeSeconds = Date.now() / 1000;
1309+
mockFunction(timestampInSeconds).mockReturnValue(autoTimeSeconds);
1310+
await _captureAppStart({ isManual: false });
1311+
1312+
// No transaction yet — deferred
1313+
expect(standaloneClient.eventQueue.length).toBe(0);
1314+
1315+
// Advance timers to fire the deferred send, then switch to real timers
1316+
// so the async captureStandaloneAppStart() can complete naturally.
1317+
jest.runAllTimers();
1318+
jest.useRealTimers();
1319+
// Flush async event processing (captureStandaloneAppStart has multiple await steps)
1320+
await new Promise(resolve => setTimeout(resolve, 50));
1321+
1322+
expect(standaloneClient.eventQueue.length).toBe(1);
1323+
const autoEvent = standaloneClient.eventQueue[0];
1324+
const autoSpan = autoEvent?.spans?.find(s => s.op === APP_START_COLD_OP);
1325+
expect(autoSpan).toBeDefined();
1326+
expect(autoSpan?.timestamp).toBeCloseTo(autoTimeSeconds, 1);
1327+
expect(autoSpan?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1);
1328+
});
1329+
13001330
it('allows auto-capture again after isAppLoadedManuallyInvoked is reset', async () => {
13011331
getCurrentScope().clear();
13021332
getIsolationScope().clear();

0 commit comments

Comments
 (0)