diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb9e79e27..f8c9030160 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)) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index ff5b98f80b..98ffdff22f 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); } }); @@ -209,8 +209,11 @@ 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): 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 +248,7 @@ export const reactNavigationIntegration = ({ tracing?.options.beforeStartSpan ? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions()) : getDefaultIdleNavigationSpanOptions(), - idleSpanOptions, + { ...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 88b54c3070..030c12028a 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 = {}, + isAppRestart = false, + }: Partial & { isAppRestart?: boolean } = {}, ): Span | undefined => { const client = getClient(); if (!client) { @@ -58,8 +59,20 @@ export const startIdleNavigationSpan = ( } const activeSpan = getActiveSpan(); + const isActiveSpanInteraction = activeSpan && isRootSpan(activeSpan) && isSentryInteractionSpan(activeSpan); + clearActiveSpanFromScope(getCurrentScope()); - if (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 && isAppRestart) { + debug.log( + `[startIdleNavigationSpan] Not canceling ${ + 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 diff --git a/packages/core/test/tracing/idleNavigationSpan.test.ts b/packages/core/test/tracing/idleNavigationSpan.test.ts index 74cca511c1..d0c598ca38 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', + }, + { isAppRestart: 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); + }); }); });