From 1ed7cfe1c53bcfd32731c32647a69c72a816c243 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 18 Mar 2026 11:24:57 +0100 Subject: [PATCH 1/4] fix(tracing): Recover app start data when first navigation transaction is discarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the first navigation idle transaction is discarded at spanEnd (e.g., by ignoreEmptyRouteChangeTransactions because the navigation container was registered too late), firstStartedActiveRootSpanId remained permanently locked to the dead span. Since no transaction event was ever created for it, processEvent never ran, and all subsequent transactions failed the span ID check — silently losing app start data for the entire session. Add a spanEnd listener that resets the lock when the first root span is unsampled at end time. Also set appStartEndData timestamp before awaiting fetchNativeFrames to close a secondary timing race, and improve failure recovery in attachAppStartToTransactionEvent. Fixes #5831 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/js/tracing/integrations/appStart.ts | 56 +++++++- .../tracing/integrations/appStart.test.ts | 133 ++++++++++++++++++ 2 files changed, 182 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 09b2263ceb..91481f27ff 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -83,22 +83,29 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro isRecordedAppStartEndTimestampMsManual = isManual; const timestampMs = timestampInSeconds() * 1000; - let endFrames: NativeFramesResponse | null = null; + + // Set end timestamp immediately to avoid race with processEvent + // Frames data will be updated after the async fetch + _setAppStartEndData({ + timestampMs, + endFrames: null, + }); if (NATIVE.enableNative) { try { - endFrames = await NATIVE.fetchNativeFrames(); + const endFrames = await NATIVE.fetchNativeFrames(); debug.log('[AppStart] Captured end frames for app start.', endFrames); + // Update the existing data with frames, not a new _setAppStartEndData call + // to avoid the "Overwriting already set app start end data" warning + appStartEndData = { + timestampMs, + endFrames, + }; } catch (error) { debug.log('[AppStart] Failed to capture end frames for app start.', error); } } - _setAppStartEndData({ - timestampMs, - endFrames, - }); - await client.getIntegrationByName(INTEGRATION_NAME)?.captureStandaloneAppStart(); } @@ -247,6 +254,33 @@ export const appStartIntegration = ({ } setFirstStartedActiveRootSpanId(rootSpan.spanContext().spanId); + + // Listen for spanEnd to detect if this root span is dropped (e.g., by + // ignoreEmptyBackNavigation or onlySampleIfChildSpans setting _sampled = false). + // If the span is unsampled at end time, no transaction event will be created and + // processEvent will never run for it — so we must reset the lock here. + _client?.on('spanEnd', (endedSpan: Span) => { + if (endedSpan !== rootSpan) { + return; + } + if (!spanIsSampled(endedSpan) && firstStartedActiveRootSpanId === rootSpan.spanContext().spanId) { + debug.log( + '[AppStart] First started active root span was unsampled at end. Resetting to allow next transaction.', + ); + resetFirstStartedActiveRootSpanId(); + } + }); + }; + + /** + * Resets the first started active root span id to allow the next + * root span's transaction to attempt app start attachment. + * Called when the matching transaction fails to attach app start + * due to a potentially transient condition (e.g., native data not yet available). + */ + const resetFirstStartedActiveRootSpanId = (): void => { + debug.log('[AppStart] Resetting first started active root span id to allow retry on next transaction.'); + firstStartedActiveRootSpanId = undefined; }; /** @@ -322,6 +356,7 @@ export const appStartIntegration = ({ async function attachAppStartToTransactionEvent(event: TransactionEvent): Promise { if (appStartDataFlushed) { // App start data is only relevant for the first transaction of the app run + debug.log('[AppStart] App start data already flushed. Skipping.'); return; } @@ -350,16 +385,19 @@ export const appStartIntegration = ({ const appStart = await NATIVE.fetchNativeAppStart(); if (!appStart) { debug.warn('[AppStart] Failed to retrieve the app start metrics from the native layer.'); + resetFirstStartedActiveRootSpanId(); return; } if (appStart.has_fetched) { debug.warn('[AppStart] Measured app start metrics were already reported from the native layer.'); + appStartDataFlushed = true; return; } const appStartTimestampMs = appStart.app_start_timestamp_ms; if (!appStartTimestampMs) { debug.warn('[AppStart] App start timestamp could not be loaded from the native layer.'); + resetFirstStartedActiveRootSpanId(); return; } @@ -368,6 +406,7 @@ export const appStartIntegration = ({ debug.warn( '[AppStart] Javascript failed to record app start end. `_setAppStartEndData` was not called nor could the bundle start be found.', ); + resetFirstStartedActiveRootSpanId(); return; } @@ -375,6 +414,7 @@ export const appStartIntegration = ({ !!event.start_timestamp && appStartTimestampMs >= event.start_timestamp * 1_000 - MAX_APP_START_AGE_MS; if (!__DEV__ && !isAppStartWithinBounds) { debug.warn('[AppStart] App start timestamp is too far in the past to be used for app start span.'); + appStartDataFlushed = true; return; } @@ -382,6 +422,7 @@ export const appStartIntegration = ({ if (!__DEV__ && appStartDurationMs >= MAX_APP_START_DURATION_MS) { // Dev builds can have long app start waiting over minute for the first bundle to be produced debug.warn('[AppStart] App start duration is over a minute long, not adding app start span.'); + appStartDataFlushed = true; return; } @@ -393,6 +434,7 @@ export const appStartIntegration = ({ '[AppStart] Last recorded app start end timestamp is before the app start timestamp.', 'This is usually caused by missing `Sentry.wrap(RootComponent)` call.', ); + resetFirstStartedActiveRootSpanId(); return; } diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index a15f931ba8..486cec259a 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -464,6 +464,57 @@ describe('App Start Integration', () => { }); }); + it('Attaches app start to next transaction when first root span is dropped at spanEnd', async () => { + mockAppStart({ cold: true }); + + const integration = appStartIntegration(); + const client = new TestClient({ + ...getDefaultTestClientOptions(), + enableAppStartTracking: true, + tracesSampleRate: 1.0, + }); + setCurrentClient(client); + integration.setup(client); + integration.afterAllSetup(client); + + // First root span starts — locks firstStartedActiveRootSpanId + const firstSpan = startInactiveSpan({ + name: 'First Navigation', + forceTransaction: true, + }); + + // Simulate the span being dropped at spanEnd (e.g., ignoreEmptyBackNavigation + // or onlySampleIfChildSpans sets _sampled = false before spanEnd fires) + (firstSpan as any)['_sampled'] = false; + client.emit('spanEnd', firstSpan); + + // Second root span starts — should now be picked up since first was reset + const secondSpan = startInactiveSpan({ + name: 'Second Navigation', + forceTransaction: true, + }); + const secondSpanId = secondSpan.spanContext().spanId; + + // Process a transaction event matching the second span + const event = getMinimalTransactionEvent(); + event.contexts!.trace!.span_id = secondSpanId; + + const actualEvent = await processEventWithIntegration(integration, event); + + // App start should be attached to the second transaction + const appStartSpan = (actualEvent as TransactionEvent)?.spans?.find( + ({ description }) => description === 'Cold Start', + ); + expect(appStartSpan).toBeDefined(); + expect(appStartSpan).toEqual( + expect.objectContaining({ + description: 'Cold Start', + op: APP_START_COLD_OP, + }), + ); + expect((actualEvent as TransactionEvent)?.measurements?.[APP_START_COLD_MEASUREMENT]).toBeDefined(); + }); + it('Does not lock firstStartedActiveRootSpanId to unsampled root span', async () => { mockAppStart({ cold: true }); @@ -894,6 +945,88 @@ describe('App Start Integration', () => { expect(actualEvent).toStrictEqual(getMinimalTransactionEvent()); expect(NATIVE.fetchNativeAppStart).toHaveBeenCalledTimes(1); }); + + it('Resets firstStartedActiveRootSpanId when native returns null so next transaction can retry', async () => { + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValueOnce(null); + + const integration = appStartIntegration(); + const client = new TestClient(getDefaultTestClientOptions()); + + const firstEvent = getMinimalTransactionEvent(); + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(firstEvent.contexts?.trace?.span_id); + + // First transaction fails because native returns null + const actualFirstEvent = await integration.processEvent(firstEvent, {}, client); + expect((actualFirstEvent as TransactionEvent).measurements).toBeUndefined(); + + // Now mock a successful native response for the retry + mockAppStart({ cold: true }); + const secondEvent = getMinimalTransactionEvent(); + secondEvent.contexts!.trace!.span_id = '456'; + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(secondEvent.contexts?.trace?.span_id); + + // Second transaction should succeed + const actualSecondEvent = await integration.processEvent(secondEvent, {}, client); + const appStartSpan = (actualSecondEvent as TransactionEvent)?.spans?.find( + ({ description }) => description === 'Cold Start', + ); + expect(appStartSpan).toBeDefined(); + }); + + it('Does not retry when has_fetched is true because data was already consumed', async () => { + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({ + type: 'cold', + has_fetched: true, + spans: [], + }); + + const integration = appStartIntegration(); + const client = new TestClient(getDefaultTestClientOptions()); + + const firstEvent = getMinimalTransactionEvent(); + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(firstEvent.contexts?.trace?.span_id); + + // First transaction fails because has_fetched is true + await integration.processEvent(firstEvent, {}, client); + + // Mock a fresh response, but it should not be tried + mockAppStart({ cold: true }); + const secondEvent = getMinimalTransactionEvent(); + secondEvent.contexts!.trace!.span_id = '456'; + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(secondEvent.contexts?.trace?.span_id); + + const actualSecondEvent = await integration.processEvent(secondEvent, {}, client); + + // appStartDataFlushed was set, so second event should not have app start + expect((actualSecondEvent as TransactionEvent).measurements).toBeUndefined(); + }); + + it('Resets firstStartedActiveRootSpanId when app start end timestamp is before app start timestamp', async () => { + mockAppStart({ cold: true, appStartEndTimestampMs: Date.now() - 1000 }); + + const integration = appStartIntegration(); + const client = new TestClient(getDefaultTestClientOptions()); + + const firstEvent = getMinimalTransactionEvent(); + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(firstEvent.contexts?.trace?.span_id); + + // First transaction fails due to negative duration + const actualFirstEvent = await integration.processEvent(firstEvent, {}, client); + expect((actualFirstEvent as TransactionEvent).measurements).toBeUndefined(); + + // Mock valid data for retry + mockAppStart({ cold: true }); + const secondEvent = getMinimalTransactionEvent(); + secondEvent.contexts!.trace!.span_id = '456'; + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(secondEvent.contexts?.trace?.span_id); + + // Second transaction should succeed + const actualSecondEvent = await integration.processEvent(secondEvent, {}, client); + const appStartSpan = (actualSecondEvent as TransactionEvent)?.spans?.find( + ({ description }) => description === 'Cold Start', + ); + expect(appStartSpan).toBeDefined(); + }); }); }); From 3cf0822b504d6159714227ac7203205768043c53 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 18 Mar 2026 11:26:50 +0100 Subject: [PATCH 2/4] fix(tracing): Add changelog entry for app start recovery fix Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06273210b1..5a64183af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixes - Fix native frames measurements being dropped due to race condition ([#5813](https://github.com/getsentry/sentry-react-native/pull/5813)) +- Fix app start data lost when first navigation transaction is discarded ([#5833](https://github.com/getsentry/sentry-react-native/pull/5833)) ## 8.4.0 From 840f888609f81a03aeb1fc77cb1944fcf9d40683 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 18 Mar 2026 11:32:58 +0100 Subject: [PATCH 3/4] fix(tracing): Address review feedback for app start recovery - Unsubscribe spanEnd listener after it fires to prevent listener leak - Add _updateAppStartEndFrames helper instead of direct module-level mutation - Make all processEvent failure paths set appStartDataFlushed = true (these conditions won't change within the same app start, retrying is wasteful) - Add comment about _sampled internal coupling in test - Fix unnecessary type assertions (lint) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/js/tracing/integrations/appStart.ts | 39 ++++++++++------ .../tracing/integrations/appStart.test.ts | 45 ++++++++----------- 2 files changed, 44 insertions(+), 40 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 91481f27ff..684a4ee328 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -95,12 +95,7 @@ export async function _captureAppStart({ isManual }: { isManual: boolean }): Pro try { const endFrames = await NATIVE.fetchNativeFrames(); debug.log('[AppStart] Captured end frames for app start.', endFrames); - // Update the existing data with frames, not a new _setAppStartEndData call - // to avoid the "Overwriting already set app start end data" warning - appStartEndData = { - timestampMs, - endFrames, - }; + _updateAppStartEndFrames(endFrames); } catch (error) { debug.log('[AppStart] Failed to capture end frames for app start.', error); } @@ -140,6 +135,19 @@ export const _setAppStartEndData = (data: AppStartEndData): void => { appStartEndData = data; }; +/** + * Updates only the endFrames on existing appStartEndData. + * Used after the async fetchNativeFrames completes to attach frame data + * without triggering the overwrite warning from _setAppStartEndData. + * + * @private + */ +export const _updateAppStartEndFrames = (endFrames: NativeFramesResponse | null): void => { + if (appStartEndData) { + appStartEndData.endFrames = endFrames; + } +}; + /** * For testing purposes only. * @@ -259,10 +267,13 @@ export const appStartIntegration = ({ // ignoreEmptyBackNavigation or onlySampleIfChildSpans setting _sampled = false). // If the span is unsampled at end time, no transaction event will be created and // processEvent will never run for it — so we must reset the lock here. - _client?.on('spanEnd', (endedSpan: Span) => { + const unsubscribe = _client?.on('spanEnd', (endedSpan: Span) => { if (endedSpan !== rootSpan) { return; } + + unsubscribe?.(); + if (!spanIsSampled(endedSpan) && firstStartedActiveRootSpanId === rootSpan.spanContext().spanId) { debug.log( '[AppStart] First started active root span was unsampled at end. Resetting to allow next transaction.', @@ -275,8 +286,8 @@ export const appStartIntegration = ({ /** * Resets the first started active root span id to allow the next * root span's transaction to attempt app start attachment. - * Called when the matching transaction fails to attach app start - * due to a potentially transient condition (e.g., native data not yet available). + * Called when the locked root span is discarded at spanEnd (e.g., by + * ignoreEmptyRouteChangeTransactions setting _sampled = false). */ const resetFirstStartedActiveRootSpanId = (): void => { debug.log('[AppStart] Resetting first started active root span id to allow retry on next transaction.'); @@ -382,10 +393,12 @@ export const appStartIntegration = ({ } } + // All failure paths below set appStartDataFlushed = true to prevent + // wasteful retries — these conditions won't change within the same app start. const appStart = await NATIVE.fetchNativeAppStart(); if (!appStart) { debug.warn('[AppStart] Failed to retrieve the app start metrics from the native layer.'); - resetFirstStartedActiveRootSpanId(); + appStartDataFlushed = true; return; } if (appStart.has_fetched) { @@ -397,7 +410,7 @@ export const appStartIntegration = ({ const appStartTimestampMs = appStart.app_start_timestamp_ms; if (!appStartTimestampMs) { debug.warn('[AppStart] App start timestamp could not be loaded from the native layer.'); - resetFirstStartedActiveRootSpanId(); + appStartDataFlushed = true; return; } @@ -406,7 +419,7 @@ export const appStartIntegration = ({ debug.warn( '[AppStart] Javascript failed to record app start end. `_setAppStartEndData` was not called nor could the bundle start be found.', ); - resetFirstStartedActiveRootSpanId(); + appStartDataFlushed = true; return; } @@ -434,7 +447,7 @@ export const appStartIntegration = ({ '[AppStart] Last recorded app start end timestamp is before the app start timestamp.', 'This is usually caused by missing `Sentry.wrap(RootComponent)` call.', ); - resetFirstStartedActiveRootSpanId(); + appStartDataFlushed = true; return; } diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 486cec259a..95f8f861d3 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -484,7 +484,9 @@ describe('App Start Integration', () => { }); // Simulate the span being dropped at spanEnd (e.g., ignoreEmptyBackNavigation - // or onlySampleIfChildSpans sets _sampled = false before spanEnd fires) + // or onlySampleIfChildSpans sets _sampled = false before spanEnd fires). + // Note: _sampled is a @sentry/core SentrySpan internal — this couples to the + // same mechanism used by onSpanEndUtils.ts (discardEmptyNavigationSpan). (firstSpan as any)['_sampled'] = false; client.emit('spanEnd', firstSpan); @@ -946,8 +948,8 @@ describe('App Start Integration', () => { expect(NATIVE.fetchNativeAppStart).toHaveBeenCalledTimes(1); }); - it('Resets firstStartedActiveRootSpanId when native returns null so next transaction can retry', async () => { - mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValueOnce(null); + it('Sets appStartDataFlushed when native returns null to prevent wasteful retries', async () => { + mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(null); const integration = appStartIntegration(); const client = new TestClient(getDefaultTestClientOptions()); @@ -955,25 +957,22 @@ describe('App Start Integration', () => { const firstEvent = getMinimalTransactionEvent(); (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(firstEvent.contexts?.trace?.span_id); - // First transaction fails because native returns null - const actualFirstEvent = await integration.processEvent(firstEvent, {}, client); - expect((actualFirstEvent as TransactionEvent).measurements).toBeUndefined(); + await integration.processEvent(firstEvent, {}, client); + expect(firstEvent.measurements).toBeUndefined(); - // Now mock a successful native response for the retry + // Second transaction should be skipped (appStartDataFlushed = true) mockAppStart({ cold: true }); const secondEvent = getMinimalTransactionEvent(); secondEvent.contexts!.trace!.span_id = '456'; (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(secondEvent.contexts?.trace?.span_id); - // Second transaction should succeed const actualSecondEvent = await integration.processEvent(secondEvent, {}, client); - const appStartSpan = (actualSecondEvent as TransactionEvent)?.spans?.find( - ({ description }) => description === 'Cold Start', - ); - expect(appStartSpan).toBeDefined(); + expect((actualSecondEvent as TransactionEvent).measurements).toBeUndefined(); + // fetchNativeAppStart should only be called once — the second event was skipped + expect(NATIVE.fetchNativeAppStart).toHaveBeenCalledTimes(1); }); - it('Does not retry when has_fetched is true because data was already consumed', async () => { + it('Sets appStartDataFlushed when has_fetched is true to prevent wasteful retries', async () => { mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({ type: 'cold', has_fetched: true, @@ -986,22 +985,19 @@ describe('App Start Integration', () => { const firstEvent = getMinimalTransactionEvent(); (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(firstEvent.contexts?.trace?.span_id); - // First transaction fails because has_fetched is true await integration.processEvent(firstEvent, {}, client); - // Mock a fresh response, but it should not be tried + // Second transaction should be skipped (appStartDataFlushed = true) mockAppStart({ cold: true }); const secondEvent = getMinimalTransactionEvent(); secondEvent.contexts!.trace!.span_id = '456'; (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(secondEvent.contexts?.trace?.span_id); const actualSecondEvent = await integration.processEvent(secondEvent, {}, client); - - // appStartDataFlushed was set, so second event should not have app start expect((actualSecondEvent as TransactionEvent).measurements).toBeUndefined(); }); - it('Resets firstStartedActiveRootSpanId when app start end timestamp is before app start timestamp', async () => { + it('Sets appStartDataFlushed when app start end timestamp is before app start timestamp', async () => { mockAppStart({ cold: true, appStartEndTimestampMs: Date.now() - 1000 }); const integration = appStartIntegration(); @@ -1010,22 +1006,17 @@ describe('App Start Integration', () => { const firstEvent = getMinimalTransactionEvent(); (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(firstEvent.contexts?.trace?.span_id); - // First transaction fails due to negative duration - const actualFirstEvent = await integration.processEvent(firstEvent, {}, client); - expect((actualFirstEvent as TransactionEvent).measurements).toBeUndefined(); + await integration.processEvent(firstEvent, {}, client); + expect(firstEvent.measurements).toBeUndefined(); - // Mock valid data for retry + // Second transaction should be skipped (appStartDataFlushed = true) mockAppStart({ cold: true }); const secondEvent = getMinimalTransactionEvent(); secondEvent.contexts!.trace!.span_id = '456'; (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(secondEvent.contexts?.trace?.span_id); - // Second transaction should succeed const actualSecondEvent = await integration.processEvent(secondEvent, {}, client); - const appStartSpan = (actualSecondEvent as TransactionEvent)?.spans?.find( - ({ description }) => description === 'Cold Start', - ); - expect(appStartSpan).toBeDefined(); + expect((actualSecondEvent as TransactionEvent).measurements).toBeUndefined(); }); }); }); From 5aae9c3e9d62ac5b58ef8ae29b74f2d890bf47fc Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 18 Mar 2026 12:14:51 +0100 Subject: [PATCH 4/4] fix(tracing): Check locked span sampled status at next spanStart instead of spanEnd The spanEnd listener approach had a listener ordering bug: the app start listener registered at spanStart time runs BEFORE the navigation discard listeners (ignoreEmptyRouteChangeTransactions, ignoreEmptyBackNavigation) that set _sampled = false. So our check always saw the span as sampled. Instead, store a reference to the locked root span and check its sampled status lazily at the next spanStart. By then, the old span has fully completed and _sampled has been set to false by the discard listeners. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/js/tracing/integrations/appStart.ts | 45 +++++++++---------- .../tracing/integrations/appStart.test.ts | 10 ++--- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 684a4ee328..bad40bcaa4 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -199,6 +199,7 @@ export const appStartIntegration = ({ let appStartDataFlushed = false; let afterAllSetupCalled = false; let firstStartedActiveRootSpanId: string | undefined = undefined; + let firstStartedActiveRootSpan: Span | undefined = undefined; const setup = (client: Client): void => { _client = client; @@ -225,6 +226,7 @@ export const appStartIntegration = ({ debug.log('[AppStartIntegration] Resetting app start data flushed flag based on runApplication call.'); appStartDataFlushed = false; firstStartedActiveRootSpanId = undefined; + firstStartedActiveRootSpan = undefined; } else { debug.log( '[AppStartIntegration] Waiting for initial app start was flush, before updating based on runApplication call.', @@ -250,7 +252,21 @@ export const appStartIntegration = ({ const recordFirstStartedActiveRootSpanId = (rootSpan: Span): void => { if (firstStartedActiveRootSpanId) { - return; + // Check if the previously locked span was dropped after it ended (e.g., by + // ignoreEmptyRouteChangeTransactions or ignoreEmptyBackNavigation setting + // _sampled = false during spanEnd). If so, reset and allow this new span. + // We check here (at the next spanStart) rather than at spanEnd because + // the discard listeners run after the app start listener in registration order, + // so _sampled is not yet false when our own spanEnd listener would fire. + if (firstStartedActiveRootSpan && !spanIsSampled(firstStartedActiveRootSpan)) { + debug.log( + '[AppStart] Previously locked root span was unsampled after ending. Resetting to allow next transaction.', + ); + resetFirstStartedActiveRootSpanId(); + // Fall through to lock to this new span + } else { + return; + } } if (!isRootSpan(rootSpan)) { @@ -261,37 +277,18 @@ export const appStartIntegration = ({ return; } + firstStartedActiveRootSpan = rootSpan; setFirstStartedActiveRootSpanId(rootSpan.spanContext().spanId); - - // Listen for spanEnd to detect if this root span is dropped (e.g., by - // ignoreEmptyBackNavigation or onlySampleIfChildSpans setting _sampled = false). - // If the span is unsampled at end time, no transaction event will be created and - // processEvent will never run for it — so we must reset the lock here. - const unsubscribe = _client?.on('spanEnd', (endedSpan: Span) => { - if (endedSpan !== rootSpan) { - return; - } - - unsubscribe?.(); - - if (!spanIsSampled(endedSpan) && firstStartedActiveRootSpanId === rootSpan.spanContext().spanId) { - debug.log( - '[AppStart] First started active root span was unsampled at end. Resetting to allow next transaction.', - ); - resetFirstStartedActiveRootSpanId(); - } - }); }; /** - * Resets the first started active root span id to allow the next - * root span's transaction to attempt app start attachment. - * Called when the locked root span is discarded at spanEnd (e.g., by - * ignoreEmptyRouteChangeTransactions setting _sampled = false). + * Resets the first started active root span id and span reference to allow + * the next root span's transaction to attempt app start attachment. */ const resetFirstStartedActiveRootSpanId = (): void => { debug.log('[AppStart] Resetting first started active root span id to allow retry on next transaction.'); firstStartedActiveRootSpanId = undefined; + firstStartedActiveRootSpan = undefined; }; /** diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 95f8f861d3..0f8ecc2a51 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -464,7 +464,7 @@ describe('App Start Integration', () => { }); }); - it('Attaches app start to next transaction when first root span is dropped at spanEnd', async () => { + it('Attaches app start to next transaction when first root span was dropped', async () => { mockAppStart({ cold: true }); const integration = appStartIntegration(); @@ -483,14 +483,14 @@ describe('App Start Integration', () => { forceTransaction: true, }); - // Simulate the span being dropped at spanEnd (e.g., ignoreEmptyBackNavigation - // or onlySampleIfChildSpans sets _sampled = false before spanEnd fires). + // Simulate the span being dropped (e.g., ignoreEmptyRouteChangeTransactions + // sets _sampled = false during spanEnd processing). // Note: _sampled is a @sentry/core SentrySpan internal — this couples to the // same mechanism used by onSpanEndUtils.ts (discardEmptyNavigationSpan). (firstSpan as any)['_sampled'] = false; - client.emit('spanEnd', firstSpan); - // Second root span starts — should now be picked up since first was reset + // Second root span starts — recordFirstStartedActiveRootSpanId detects + // the previously locked span is no longer sampled and resets the lock const secondSpan = startInactiveSpan({ name: 'Second Navigation', forceTransaction: true,