Skip to content

Commit 7e9c42c

Browse files
authored
fix(replay): Preserve interaction spans during app restart to fix replay capture (#5386)
* fix(replay): Preserve interaction spans during app restart to fix replay capture * Adds changelog * Fix changelog spacing * ref: rename to isAppRestart * Adds comment for the startIdleNavigationSpan params * Call clearActiveSpanFromScope in all cases
1 parent 3401245 commit 7e9c42c

File tree

4 files changed

+77
-5
lines changed

4 files changed

+77
-5
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
- 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.
1515
- Takes effect only if `enableLogs` is `true` and defaults to 'all', preserving previous behavior.
1616

17+
### Fixes
18+
19+
- Preserves interaction span context during app restart to allow proper replay capture ([#5386](https://github.com/getsentry/sentry-react-native/pull/5386))
20+
1721
### Dependencies
1822

1923
- Bump JavaScript SDK from v10.24.0 to v10.25.0 ([#5362](https://github.com/getsentry/sentry-react-native/pull/5362))

packages/core/src/js/tracing/reactnavigation.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export const reactNavigationIntegration = ({
141141
// This ensures runApplication calls after the initial start are correctly traced.
142142
// This is used for example when Activity is (re)started on Android.
143143
debug.log('[ReactNavigationIntegration] Starting new idle navigation span based on runApplication call.');
144-
startIdleNavigationSpan();
144+
startIdleNavigationSpan(undefined, true);
145145
}
146146
});
147147

@@ -209,8 +209,11 @@ export const reactNavigationIntegration = ({
209209
* To be called on every React-Navigation action dispatch.
210210
* It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change
211211
* and gets the route information from there, @see updateLatestNavigationSpanWithCurrentRoute
212+
*
213+
* @param unknownEvent - The event object that contains navigation action data
214+
* @param isAppRestart - Whether this span is being started due to an app restart rather than a normal navigation action
212215
*/
213-
const startIdleNavigationSpan = (unknownEvent?: unknown): void => {
216+
const startIdleNavigationSpan = (unknownEvent?: unknown, isAppRestart = false): void => {
214217
const event = unknownEvent as UnsafeAction | undefined;
215218
if (useDispatchedActionData && event?.data.noop) {
216219
debug.log(`${INTEGRATION_NAME} Navigation action is a noop, not starting navigation span.`);
@@ -245,7 +248,7 @@ export const reactNavigationIntegration = ({
245248
tracing?.options.beforeStartSpan
246249
? tracing.options.beforeStartSpan(getDefaultIdleNavigationSpanOptions())
247250
: getDefaultIdleNavigationSpanOptions(),
248-
idleSpanOptions,
251+
{ ...idleSpanOptions, isAppRestart },
249252
);
250253
latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION);
251254
latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, navigationActionType);

packages/core/src/js/tracing/span.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ export const startIdleNavigationSpan = (
4949
{
5050
finalTimeout = defaultIdleOptions.finalTimeout,
5151
idleTimeout = defaultIdleOptions.idleTimeout,
52-
}: Partial<typeof defaultIdleOptions> = {},
52+
isAppRestart = false,
53+
}: Partial<typeof defaultIdleOptions> & { isAppRestart?: boolean } = {},
5354
): Span | undefined => {
5455
const client = getClient();
5556
if (!client) {
@@ -58,8 +59,20 @@ export const startIdleNavigationSpan = (
5859
}
5960

6061
const activeSpan = getActiveSpan();
62+
const isActiveSpanInteraction = activeSpan && isRootSpan(activeSpan) && isSentryInteractionSpan(activeSpan);
63+
6164
clearActiveSpanFromScope(getCurrentScope());
62-
if (activeSpan && isRootSpan(activeSpan) && isSentryInteractionSpan(activeSpan)) {
65+
66+
// Don't cancel user interaction spans when starting from runApplication (app restart/reload).
67+
// This preserves the span context for error capture and replay recording.
68+
if (isActiveSpanInteraction && isAppRestart) {
69+
debug.log(
70+
`[startIdleNavigationSpan] Not canceling ${
71+
spanToJSON(activeSpan).op
72+
} transaction because navigation is from app restart - preserving error context.`,
73+
);
74+
// Don't end the span - it will timeout naturally and remains available for error/replay processing
75+
} else if (isActiveSpanInteraction) {
6376
debug.log(
6477
`[startIdleNavigationSpan] Canceling ${
6578
spanToJSON(activeSpan).op

packages/core/test/tracing/idleNavigationSpan.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,58 @@ describe('startIdleNavigationSpan', () => {
169169
expect(newSpan).toBe(getActiveSpan());
170170
expect(spanToJSON(newSpan!).parent_span_id).toBeUndefined();
171171
});
172+
173+
it('Cancels user interaction span during normal navigation', () => {
174+
const userInteractionSpan = startSpanManual(
175+
{
176+
name: 'ui.action.touch',
177+
op: 'ui.action.touch',
178+
attributes: {
179+
'sentry.origin': 'auto.interaction',
180+
},
181+
},
182+
(span: Span) => span,
183+
);
184+
setActiveSpanOnScope(getCurrentScope(), userInteractionSpan);
185+
186+
const navigationSpan = startIdleNavigationSpan({
187+
name: 'test',
188+
});
189+
190+
expect(spanToJSON(userInteractionSpan).timestamp).toBeDefined();
191+
expect(spanToJSON(userInteractionSpan).status).toBe('cancelled');
192+
193+
expect(navigationSpan).toBe(getActiveSpan());
194+
});
195+
196+
it('Does NOT cancel user interaction span when navigation starts from runApplication (app restart)', () => {
197+
const userInteractionSpan = startSpanManual(
198+
{
199+
name: 'ui.action.touch',
200+
op: 'ui.action.touch',
201+
attributes: {
202+
'sentry.origin': 'auto.interaction',
203+
},
204+
},
205+
(span: Span) => span,
206+
);
207+
setActiveSpanOnScope(getCurrentScope(), userInteractionSpan);
208+
209+
// Start navigation span from runApplication (app restart/reload - e.g. after error)
210+
const navigationSpan = startIdleNavigationSpan(
211+
{
212+
name: 'test',
213+
},
214+
{ isAppRestart: true },
215+
);
216+
217+
// User interaction span should NOT be cancelled/ended - preserving it for replay capture
218+
expect(spanToJSON(userInteractionSpan).timestamp).toBeUndefined();
219+
expect(spanToJSON(userInteractionSpan).status).not.toBe('cancelled');
220+
221+
expect(navigationSpan).toBeDefined();
222+
expect(getActiveSpan()).toBe(navigationSpan);
223+
});
172224
});
173225
});
174226

0 commit comments

Comments
 (0)