From e572a8818f34bfaa4cad75a60bf6fe36878db60d Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 19 Nov 2025 11:29:58 +0100 Subject: [PATCH 1/6] fix(replay): Preserve interaction spans during app restart to fix replay capture --- .../core/src/js/tracing/reactnavigation.ts | 6 +-- packages/core/src/js/tracing/span.ts | 19 +++++-- .../test/tracing/idleNavigationSpan.test.ts | 52 +++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index ff5b98f80b..bfe55064b9 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -141,7 +141,7 @@ export const reactNavigationIntegration = ({ // This ensures runApplication calls after the initial start are correctly traced. // This is used for example when Activity is (re)started on Android. debug.log('[ReactNavigationIntegration] Starting new idle navigation span based on runApplication call.'); - startIdleNavigationSpan(); + startIdleNavigationSpan(undefined, true); } }); @@ -210,7 +210,7 @@ export const reactNavigationIntegration = ({ * It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change * and gets the route information from there, @see updateLatestNavigationSpanWithCurrentRoute */ - const startIdleNavigationSpan = (unknownEvent?: unknown): void => { + const startIdleNavigationSpan = (unknownEvent?: unknown, isFromRunApplication = false): void => { const event = unknownEvent as UnsafeAction | undefined; if (useDispatchedActionData && event?.data.noop) { debug.log(`${INTEGRATION_NAME} Navigation action is a noop, not starting navigation span.`); @@ -245,7 +245,7 @@ export const reactNavigationIntegration = ({ tracing?.options.beforeStartSpan ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) : getDefaultIdleNavigationSpanOptions(), - idleSpanOptions, + { ...idleSpanOptions, isFromRunApplication }, ); latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION); latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, navigationActionType); diff --git a/packages/core/src/js/tracing/span.ts b/packages/core/src/js/tracing/span.ts index 88b54c3070..29769ec884 100644 --- a/packages/core/src/js/tracing/span.ts +++ b/packages/core/src/js/tracing/span.ts @@ -49,7 +49,8 @@ export const startIdleNavigationSpan = ( { finalTimeout = defaultIdleOptions.finalTimeout, idleTimeout = defaultIdleOptions.idleTimeout, - }: Partial = {}, + isFromRunApplication = false, + }: Partial & { isFromRunApplication?: boolean } = {}, ): Span | undefined => { const client = getClient(); if (!client) { @@ -58,15 +59,27 @@ export const startIdleNavigationSpan = ( } const activeSpan = getActiveSpan(); - clearActiveSpanFromScope(getCurrentScope()); - if (activeSpan && isRootSpan(activeSpan) && isSentryInteractionSpan(activeSpan)) { + const isActiveSpanInteraction = activeSpan && isRootSpan(activeSpan) && isSentryInteractionSpan(activeSpan); + + // Don't cancel user interaction spans when starting from runApplication (app restart/reload). + // This preserves the span context for error capture and replay recording. + if (isActiveSpanInteraction && isFromRunApplication) { + debug.log( + `[startIdleNavigationSpan] Not canceling ${ + spanToJSON(activeSpan).op + } transaction because navigation is from app restart - preserving error context.`, + ); + } else if (isActiveSpanInteraction) { debug.log( `[startIdleNavigationSpan] Canceling ${ spanToJSON(activeSpan).op } transaction because of a new navigation root span.`, ); + clearActiveSpanFromScope(getCurrentScope()); activeSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); activeSpan.end(); + } else { + clearActiveSpanFromScope(getCurrentScope()); } const finalStartSpanOptions = { diff --git a/packages/core/test/tracing/idleNavigationSpan.test.ts b/packages/core/test/tracing/idleNavigationSpan.test.ts index 74cca511c1..0ca675fab8 100644 --- a/packages/core/test/tracing/idleNavigationSpan.test.ts +++ b/packages/core/test/tracing/idleNavigationSpan.test.ts @@ -169,6 +169,58 @@ describe('startIdleNavigationSpan', () => { expect(newSpan).toBe(getActiveSpan()); expect(spanToJSON(newSpan!).parent_span_id).toBeUndefined(); }); + + it('Cancels user interaction span during normal navigation', () => { + const userInteractionSpan = startSpanManual( + { + name: 'ui.action.touch', + op: 'ui.action.touch', + attributes: { + 'sentry.origin': 'auto.interaction', + }, + }, + (span: Span) => span, + ); + setActiveSpanOnScope(getCurrentScope(), userInteractionSpan); + + const navigationSpan = startIdleNavigationSpan({ + name: 'test', + }); + + expect(spanToJSON(userInteractionSpan).timestamp).toBeDefined(); + expect(spanToJSON(userInteractionSpan).status).toBe('cancelled'); + + expect(navigationSpan).toBe(getActiveSpan()); + }); + + it('Does NOT cancel user interaction span when navigation starts from runApplication (app restart)', () => { + const userInteractionSpan = startSpanManual( + { + name: 'ui.action.touch', + op: 'ui.action.touch', + attributes: { + 'sentry.origin': 'auto.interaction', + }, + }, + (span: Span) => span, + ); + setActiveSpanOnScope(getCurrentScope(), userInteractionSpan); + + // Start navigation span from runApplication (app restart/reload - e.g. after error) + const navigationSpan = startIdleNavigationSpan( + { + name: 'test', + }, + { isFromRunApplication: true }, + ); + + // User interaction span should NOT be cancelled/ended - preserving it for replay capture + expect(spanToJSON(userInteractionSpan).timestamp).toBeUndefined(); + expect(spanToJSON(userInteractionSpan).status).not.toBe('cancelled'); + + expect(navigationSpan).toBeDefined(); + expect(getActiveSpan()).toBe(navigationSpan); + }); }); }); From e8e9f2e26db1c1f83ce758478e6ff1153a3bd644 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 19 Nov 2025 11:32:53 +0100 Subject: [PATCH 2/6] Adds changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb9e79e27..729379513f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - You can now choose which logs are captured: 'native' for logs from native code only, 'js' for logs from the JavaScript layer only, or 'all' for both layers. - Takes effect only if `enableLogs` is `true` and defaults to 'all', preserving previous behavior. +### Fixes + +- Preserves interaction span context during app restart to allow proper replay capture([#5386](https://github.com/getsentry/sentry-react-native/pull/5386)) + ### Dependencies - Bump JavaScript SDK from v10.24.0 to v10.25.0 ([#5362](https://github.com/getsentry/sentry-react-native/pull/5362)) From eb5730276a102e8f3de6afcce3052cb3e7a3f6da Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 19 Nov 2025 13:18:22 +0100 Subject: [PATCH 3/6] Fix changelog spacing --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 729379513f..f8c9030160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ ### Fixes -- Preserves interaction span context during app restart to allow proper replay capture([#5386](https://github.com/getsentry/sentry-react-native/pull/5386)) +- Preserves interaction span context during app restart to allow proper replay capture ([#5386](https://github.com/getsentry/sentry-react-native/pull/5386)) ### Dependencies From 6d12e05a9967fe90d2572bd75f350b05a3b96a45 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 19 Nov 2025 14:55:45 +0100 Subject: [PATCH 4/6] ref: rename to isAppRestart --- packages/core/src/js/tracing/reactnavigation.ts | 4 ++-- packages/core/src/js/tracing/span.ts | 6 +++--- packages/core/test/tracing/idleNavigationSpan.test.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index bfe55064b9..2c58bd5fe7 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -210,7 +210,7 @@ export const reactNavigationIntegration = ({ * It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change * and gets the route information from there, @see updateLatestNavigationSpanWithCurrentRoute */ - const startIdleNavigationSpan = (unknownEvent?: unknown, isFromRunApplication = false): void => { + const startIdleNavigationSpan = (unknownEvent?: unknown, isAppRestart = false): void => { const event = unknownEvent as UnsafeAction | undefined; if (useDispatchedActionData && event?.data.noop) { debug.log(`${INTEGRATION_NAME} Navigation action is a noop, not starting navigation span.`); @@ -245,7 +245,7 @@ export const reactNavigationIntegration = ({ tracing?.options.beforeStartSpan ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) : getDefaultIdleNavigationSpanOptions(), - { ...idleSpanOptions, isFromRunApplication }, + { ...idleSpanOptions, isAppRestart }, ); latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION); latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, navigationActionType); diff --git a/packages/core/src/js/tracing/span.ts b/packages/core/src/js/tracing/span.ts index 29769ec884..963ecc3a8a 100644 --- a/packages/core/src/js/tracing/span.ts +++ b/packages/core/src/js/tracing/span.ts @@ -49,8 +49,8 @@ export const startIdleNavigationSpan = ( { finalTimeout = defaultIdleOptions.finalTimeout, idleTimeout = defaultIdleOptions.idleTimeout, - isFromRunApplication = false, - }: Partial & { isFromRunApplication?: boolean } = {}, + isAppRestart = false, + }: Partial & { isAppRestart?: boolean } = {}, ): Span | undefined => { const client = getClient(); if (!client) { @@ -63,7 +63,7 @@ export const startIdleNavigationSpan = ( // Don't cancel user interaction spans when starting from runApplication (app restart/reload). // This preserves the span context for error capture and replay recording. - if (isActiveSpanInteraction && isFromRunApplication) { + if (isActiveSpanInteraction && isAppRestart) { debug.log( `[startIdleNavigationSpan] Not canceling ${ spanToJSON(activeSpan).op diff --git a/packages/core/test/tracing/idleNavigationSpan.test.ts b/packages/core/test/tracing/idleNavigationSpan.test.ts index 0ca675fab8..d0c598ca38 100644 --- a/packages/core/test/tracing/idleNavigationSpan.test.ts +++ b/packages/core/test/tracing/idleNavigationSpan.test.ts @@ -211,7 +211,7 @@ describe('startIdleNavigationSpan', () => { { name: 'test', }, - { isFromRunApplication: true }, + { isAppRestart: true }, ); // User interaction span should NOT be cancelled/ended - preserving it for replay capture From 9ea77cd2bcb8737f4fa7673bef943001ee3de5e9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 19 Nov 2025 15:12:33 +0100 Subject: [PATCH 5/6] Adds comment for the startIdleNavigationSpan params --- packages/core/src/js/tracing/reactnavigation.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 2c58bd5fe7..98ffdff22f 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -209,6 +209,9 @@ export const reactNavigationIntegration = ({ * To be called on every React-Navigation action dispatch. * It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change * and gets the route information from there, @see updateLatestNavigationSpanWithCurrentRoute + * + * @param unknownEvent - The event object that contains navigation action data + * @param isAppRestart - Whether this span is being started due to an app restart rather than a normal navigation action */ const startIdleNavigationSpan = (unknownEvent?: unknown, isAppRestart = false): void => { const event = unknownEvent as UnsafeAction | undefined; From fe5b5c19b7be4cdb0dfc515d647817c20b6e2ebe Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 19 Nov 2025 15:43:48 +0100 Subject: [PATCH 6/6] Call clearActiveSpanFromScope in all cases --- packages/core/src/js/tracing/span.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/js/tracing/span.ts b/packages/core/src/js/tracing/span.ts index 963ecc3a8a..030c12028a 100644 --- a/packages/core/src/js/tracing/span.ts +++ b/packages/core/src/js/tracing/span.ts @@ -61,6 +61,8 @@ export const startIdleNavigationSpan = ( const activeSpan = getActiveSpan(); const isActiveSpanInteraction = activeSpan && isRootSpan(activeSpan) && isSentryInteractionSpan(activeSpan); + clearActiveSpanFromScope(getCurrentScope()); + // Don't cancel user interaction spans when starting from runApplication (app restart/reload). // This preserves the span context for error capture and replay recording. if (isActiveSpanInteraction && isAppRestart) { @@ -69,17 +71,15 @@ export const startIdleNavigationSpan = ( spanToJSON(activeSpan).op } transaction because navigation is from app restart - preserving error context.`, ); + // Don't end the span - it will timeout naturally and remains available for error/replay processing } else if (isActiveSpanInteraction) { debug.log( `[startIdleNavigationSpan] Canceling ${ spanToJSON(activeSpan).op } transaction because of a new navigation root span.`, ); - clearActiveSpanFromScope(getCurrentScope()); activeSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' }); activeSpan.end(); - } else { - clearActiveSpanFromScope(getCurrentScope()); } const finalStartSpanOptions = {