diff --git a/CHANGELOG.md b/CHANGELOG.md index 89bdb8132e..80d0028f90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Correlate deep links with the navigation transaction they trigger. The next idle navigation span started within `routeChangeTimeoutMs` of a deep link arrival is tagged with `navigation.trigger: 'deeplink'`, `deeplink.url` (sanitized, respects `sendDefaultPii`), and `deeplink.dispatch_delay_ms` (ms gap between URL received and navigation dispatched). Covers both cold start (`Linking.getInitialURL()`) and warm open (`'url'` event) paths, including the late-arrival case where Expo Router auto-handles the link before our `getInitialURL()` chain resolves ([#6264](https://github.com/getsentry/sentry-react-native/pull/6264)) - Add memory, CPU, and frame measurements to Android profiling ([#6250](https://github.com/getsentry/sentry-react-native/pull/6250)) - Add `enableAutoConsoleLogs` option to opt out of automatic `console.*` capture while keeping `enableLogs: true` for manual `Sentry.logger.*` calls ([#6235](https://github.com/getsentry/sentry-react-native/pull/6235)) - Instrument Expo Router `push`, `replace`, `navigate`, `back`, and `dismiss` (in addition to `prefetch`) with breadcrumbs and spans, and tag the resulting idle navigation span with the initiating `navigation.method` ([#6221](https://github.com/getsentry/sentry-react-native/pull/6221)) diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index a4b02e65c0..adccaaaa7b 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -900,7 +900,7 @@ export function wrapTurboModule(name: string, module: T | null // src/js/feedback/integration.ts:21:5 - (ae-forgotten-export) The symbol "ScreenshotButtonProps" needs to be exported by the entry point index.d.ts // src/js/feedback/integration.ts:23:5 - (ae-forgotten-export) The symbol "FeedbackFormTheme" needs to be exported by the entry point index.d.ts // src/js/tracing/reactnativetracing.ts:90:3 - (ae-forgotten-export) The symbol "ReactNativeTracingState" needs to be exported by the entry point index.d.ts -// src/js/tracing/reactnavigation.ts:220:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts +// src/js/tracing/reactnavigation.ts:228:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts index e01f045fb1..72ed00e46c 100644 --- a/packages/core/src/js/integrations/deeplink.ts +++ b/packages/core/src/js/integrations/deeplink.ts @@ -2,6 +2,9 @@ import type { IntegrationFn } from '@sentry/core'; import { addBreadcrumb, defineIntegration, getClient } from '@sentry/core'; +import type { DeepLinkSource } from '../tracing/pendingDeepLink'; + +import { setPendingDeepLink } from '../tracing/pendingDeepLink'; import { sanitizeUrl } from '../tracing/utils'; export const INTEGRATION_NAME = 'DeepLink'; @@ -20,8 +23,11 @@ interface RNLinking { * to avoid capturing PII in path segments when `sendDefaultPii` is off. * * Only replaces segments that look like identifiers (all digits, UUIDs, or hex strings). + * + * Exported so the navigation integration can apply the same sanitization when + * attaching a deep link URL to a navigation span. */ -function sanitizeDeepLinkUrl(url: string): string { +export function sanitizeDeepLinkUrl(url: string): string { const stripped = sanitizeUrl(url); // Split off the scheme+authority (e.g. "myapp://host") so the regex @@ -55,7 +61,13 @@ function getBreadcrumbUrl(url: string): string { return sendDefaultPii ? url : sanitizeDeepLinkUrl(url); } -function addDeepLinkBreadcrumb(url: string): void { +function recordDeepLink(url: string, source: DeepLinkSource): void { + // Hand off to the navigation integration so the next idle navigation span + // can attribute itself to this deep link. Always stores the raw URL — + // sanitization (if any) happens at attach time, based on the client's + // `sendDefaultPii` option at that moment. + setPendingDeepLink(url, source); + const breadcrumbUrl = getBreadcrumbUrl(url); addBreadcrumb({ category: 'deeplink', @@ -87,7 +99,7 @@ const _deeplinkIntegration: IntegrationFn = () => { .getInitialURL() .then((url: string | null) => { if (url) { - addDeepLinkBreadcrumb(url); + recordDeepLink(url, 'cold-start'); } }) .catch(() => { @@ -97,7 +109,7 @@ const _deeplinkIntegration: IntegrationFn = () => { // Warm open: deep link received while app is running subscription = linking.addEventListener('url', (event: { url: string }) => { if (event?.url) { - addDeepLinkBreadcrumb(event.url); + recordDeepLink(event.url, 'warm-open'); } }); diff --git a/packages/core/src/js/tracing/pendingDeepLink.ts b/packages/core/src/js/tracing/pendingDeepLink.ts new file mode 100644 index 0000000000..49d16d4486 --- /dev/null +++ b/packages/core/src/js/tracing/pendingDeepLink.ts @@ -0,0 +1,138 @@ +/** + * Cross-module hand-off between the {@link deeplinkIntegration} and the + * {@link reactNavigationIntegration} idle navigation span. + * + * Two delivery modes are supported, both of which need to work in practice: + * + * 1. **Pre-navigation (warm open / normal cold start):** the deep link is + * received before any navigation has been dispatched. The URL is stored in + * a single slot here; the next idle navigation span consumes it inside + * `updateLatestNavigationSpanWithCurrentRoute` (within `routeChangeTimeoutMs`). + * + * 2. **Late arrival (Expo Router auto-handled cold start):** Expo Router reads + * `Linking.getInitialURL()` independently and may finish the initial + * navigation *before* our integration's own `getInitialURL().then(...)` + * chain resolves. To still attribute that span, a synchronous listener may + * be registered (by the navigation integration) and receives every link as + * it arrives. If it tags a still-recording span, it returns `true` and the + * slot is left empty — otherwise the link falls through to the slot. + */ + +/** + * How the deep link reached the integration: + * - `cold-start` — from `Linking.getInitialURL()`. The app may have been + * launched by the link; the *initial* navigation is plausibly its target, + * so retroactive attribution to an already-mounted initial span is allowed. + * - `warm-open` — from the `'url'` event while the app was running. The + * triggered navigation has not happened yet, so the URL must wait in the + * pending slot — retroactively tagging the *previous* navigation would + * attribute the link to the wrong span. + */ +export type DeepLinkSource = 'cold-start' | 'warm-open'; + +export interface PendingDeepLink { + /** Raw URL as received from React Native's `Linking` API. */ + url: string; + /** Wall-clock timestamp (ms since epoch) when the URL was received. */ + receivedAtMs: number; + /** How the link arrived — governs retroactive attribution rules. */ + source: DeepLinkSource; + /** + * Monotonic sequence number shared with `nextEventSeq()`. The navigation + * integration tags every dispatched nav span with a seq from the same + * sequence — a warm-open link must only consume the slot for spans whose + * seq is strictly greater than the link's, i.e. were dispatched *after* the + * link arrived. + */ + seq: number; +} + +/** + * Synchronously notified for every deep link as it arrives. A `true` return + * value indicates the listener has already attributed the link to a live span, + * and the value should NOT be stored for a future navigation. + */ +export type PendingDeepLinkListener = (link: PendingDeepLink) => boolean; + +let pending: PendingDeepLink | undefined; +let listener: PendingDeepLinkListener | undefined; +let seqCounter = 0; + +/** + * Returns the next value in the shared monotonic sequence. Used by the + * navigation integration to stamp each dispatched nav span, so consumers can + * tell whether a pending link arrived before or after a given dispatch. + */ +export function nextEventSeq(): number { + return ++seqCounter; +} + +/** + * Stores the most recently received deep link URL together with the current + * timestamp. If a listener is registered and consumes the link synchronously, + * the slot is left empty. + * + * Overwrites any previous unconsumed pending value — only the latest link + * matters for correlation with the next navigation. + */ +export function setPendingDeepLink(url: string, source: DeepLinkSource): void { + const value: PendingDeepLink = { + url, + receivedAtMs: Date.now(), + source, + seq: nextEventSeq(), + }; + if (listener?.(value)) { + return; + } + pending = value; +} + +/** + * Returns the pending deep link without clearing the slot, applying the same + * staleness check as {@link consumePendingDeepLink}. A stale value is dropped + * (so subsequent calls do not return it) but no fresh value is returned. + */ +export function peekPendingDeepLink(maxAgeMs: number): PendingDeepLink | undefined { + if (!pending) { + return undefined; + } + if (Date.now() - pending.receivedAtMs > maxAgeMs) { + pending = undefined; + return undefined; + } + return pending; +} + +/** + * Returns and clears the pending deep link, but only if it was received + * within `maxAgeMs` of "now". Stale entries are discarded and the slot is + * cleared in all cases. + */ +export function consumePendingDeepLink(maxAgeMs: number): PendingDeepLink | undefined { + const value = pending; + pending = undefined; + if (!value) { + return undefined; + } + if (Date.now() - value.receivedAtMs > maxAgeMs) { + return undefined; + } + return value; +} + +/** + * Registers a synchronous listener that is invoked on every {@link setPendingDeepLink} + * call. Pass `undefined` to unregister. Only a single listener is supported — + * a new registration replaces the previous one. + */ +export function setPendingDeepLinkListener(fn: PendingDeepLinkListener | undefined): void { + listener = fn; +} + +/** Test helper — clears the pending value, listener, and sequence counter. */ +export function clearPendingDeepLink(): void { + pending = undefined; + listener = undefined; + seqCounter = 0; +} diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 9aefde5ebf..d56f9c7262 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -15,9 +15,11 @@ import { } from '@sentry/core'; import type { UnsafeAction } from '../vendor/react-navigation/types'; +import type { PendingDeepLink } from './pendingDeepLink'; import type { ReactNativeTracingIntegration } from './reactnativetracing'; import { getAppRegistryIntegration } from '../integrations/appRegistry'; +import { sanitizeDeepLinkUrl } from '../integrations/deeplink'; import { isSentrySpan } from '../utils/span'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { NATIVE } from '../wrapper'; @@ -27,6 +29,12 @@ import { markRootSpanForDiscard, } from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin'; +import { + consumePendingDeepLink, + nextEventSeq, + peekPendingDeepLink, + setPendingDeepLinkListener, +} from './pendingDeepLink'; import { consumePendingExpoRouterNavigation } from './pendingExpoRouterNavigation'; import { getReactNativeTracingIntegration } from './reactnativetracing'; import { SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; @@ -230,6 +238,88 @@ export const reactNavigationIntegration = ({ let latestNavigationSpan: Span | undefined; let latestNavigationSpanNameCustomized: boolean = false; let navigationProcessingSpan: Span | undefined; + /** + * The first nav span that successfully completed a state change — i.e. the + * span that mounted the app's initial route. Used (and only used) by the + * deep-link listener as a retroactive attribution target for `cold-start` + * links that arrive after that state change but before the span's idle + * timeout fires. This is the Expo-Router-auto-handled-cold-start case. + * + * Never updated after the first successful state change — a `cold-start` + * link must never retroactively tag a span the user navigated to later. + * `warm-open` links bypass this entirely and always wait in the pending + * slot for the next dispatched navigation. + */ + let initialFinalizedNavSpan: Span | undefined; + + /** + * Monotonic dispatch sequence per span, drawn from the shared `nextEventSeq` + * counter. Used to reject warm-open links that arrived *before* a dispatch — + * such links cannot have caused that dispatch, so the span must not be + * tagged with them. + */ + const spanDispatchSeq = new WeakMap(); + + /** + * Attempts to attach the pending deep link to the given span. + * + * Warm-open links only attach to spans dispatched *after* the link was + * received. This prevents an unrelated, already-in-flight navigation from + * being tagged when a deep link arrives mid-dispatch but is actually the + * trigger of a subsequent navigation. + * + * Cold-start links attach unconditionally — retroactive attribution to the + * initial nav span is the whole point of that source. + * + * Rejected warm-open links are left in the slot to be picked up by the next + * eligible span. + */ + const applyPendingDeepLinkToSpan = (span: Span, maxAgeMs: number): void => { + const pending = peekPendingDeepLink(maxAgeMs); + if (!pending) { + return; + } + if (pending.source === 'warm-open') { + const spanSeq = spanDispatchSeq.get(span); + if (spanSeq === undefined || spanSeq <= pending.seq) { + // Span was dispatched before (or at the same tick as) the link arrived + // — it cannot be the navigation the link triggered. Leave the link in + // the slot for the next eligible span. + return; + } + } + consumePendingDeepLink(maxAgeMs); + tagSpanWithDeepLink(span, pending); + }; + + /** + * Synchronous listener invoked the moment a deep link is recorded. Returns + * `true` only when the link was actually attached to a span — in that case + * the pendingDeepLink module skips storing the value. Returns `false` to let + * the link fall through to the pending slot for the next dispatched nav. + * + * Only `cold-start` links may retroactively tag an existing span. The + * realistic warm-open flow is "`'url'` event → user handler synchronously + * calls `navigation.navigate`": at listener invocation time no link-driven + * dispatch has happened yet, so any span we could reach belongs to an + * unrelated, prior navigation. + */ + const handleLateDeepLink = (link: PendingDeepLink): boolean => { + if (link.source !== 'cold-start') { + return false; + } + // Prefer an in-flight span (dispatch happened, state change pending). + if (latestNavigationSpan && isSpanRecording(latestNavigationSpan)) { + return tagSpanWithDeepLink(latestNavigationSpan, link); + } + // Fallback: the initial nav span may have already mounted its route but + // still be recording within its idle window (e.g. Expo Router auto-handled + // the link before our own `getInitialURL()` chain resolved). + if (initialFinalizedNavSpan && isSpanRecording(initialFinalizedNavSpan)) { + return tagSpanWithDeepLink(initialFinalizedNavSpan, link); + } + return false; + }; let initialStateHandled: boolean = false; let isSetupComplete: boolean = false; @@ -254,6 +344,14 @@ export const reactNavigationIntegration = ({ }; } + // Listen for deep links as they arrive so we can attribute a span that has + // already mounted its route but not yet ended (e.g. Expo Router auto-handled + // the link before our integration's `getInitialURL()` chain resolved). + setPendingDeepLinkListener(handleLateDeepLink); + client.on('close', () => { + setPendingDeepLinkListener(undefined); + }); + if (initialStateHandled) { // We create an initial state here to ensure a transaction gets created before the first route mounts. // This assumes that the Sentry.init() call is made before the first route mounts. @@ -463,6 +561,18 @@ export const reactNavigationIntegration = ({ if (pendingExpoRouter && latestNavigationSpan) { latestNavigationSpan.setAttribute('navigation.method', pendingExpoRouter.method); } + + // Stamp the span with a monotonic sequence so the deep-link consumer can + // determine whether a pending link arrived before or after this dispatch. + if (latestNavigationSpan) { + spanDispatchSeq.set(latestNavigationSpan, nextEventSeq()); + } + + // We deliberately do NOT consume the pending deep link here — if this span + // is later discarded (noop / timeout / empty route), a still-fresh pending + // value must remain available for the next nav. The pending value is + // consumed once a span actually mounts its route (see + // `updateLatestNavigationSpanWithCurrentRoute`). if (ignoreEmptyBackNavigationTransactions) { ignoreEmptyBackNavigation(getClient(), latestNavigationSpan); } @@ -519,6 +629,11 @@ export const reactNavigationIntegration = ({ if (previousRoute?.key === route.key) { debug.log(`[${INTEGRATION_NAME}] Navigation state changed, but route is the same as previous.`); + // Even a same-route state change is a legitimate destination for a + // deep link (e.g. deep-linking to the screen you're already on). Make + // sure the pending link still gets attributed before we drop the span + // reference. + applyPendingDeepLinkToSpan(latestNavigationSpan, routeChangeTimeoutMs); pushRecentRouteKey(route.key); latestRoute = route; @@ -555,6 +670,18 @@ export const reactNavigationIntegration = ({ routeName = getPathFromState(navigationState) || route.name; } + // Consume any pending deep link and attach it to this span. Done here + // (after route info is known) so the link is only attributed to a span + // that actually mounted a route — not one that was later discarded. + applyPendingDeepLinkToSpan(latestNavigationSpan, routeChangeTimeoutMs); + + // Capture the first finalized nav span for the cold-start late-arrival + // fallback. Set exactly once, then frozen — a cold-start link must never + // retroactively tag a navigation the user performed later. + if (!initialFinalizedNavSpan) { + initialFinalizedNavSpan = latestNavigationSpan; + } + navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${routeName} mounted`); navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK }); navigationProcessingSpan?.end(stateChangedTimestamp); @@ -673,6 +800,43 @@ interface NavigationContainer { getState: () => NavigationState | undefined; } +/** + * Per-span guard against double-tagging deep-link attributes. Shared between + * the synchronous listener path (late arrival) and the post-state-change path. + */ +const taggedDeepLinkSpans = new WeakSet(); + +/** + * Annotates the given span with deep-link attributes if it has not already + * been annotated. Returns `true` when the span was newly tagged, `false` when + * it was already tagged (so callers can decide whether to keep the link + * around for another span). + */ +function tagSpanWithDeepLink(span: Span, link: PendingDeepLink): boolean { + if (taggedDeepLinkSpans.has(span)) { + return false; + } + taggedDeepLinkSpans.add(span); + + const sendDefaultPii = getClient()?.getOptions()?.sendDefaultPii ?? false; + const url = sendDefaultPii ? link.url : sanitizeDeepLinkUrl(link.url); + + span.setAttributes({ + 'navigation.trigger': 'deeplink', + 'deeplink.url': url, + // Duration between URL receipt and the moment the span is annotated — + // approximates the gap between "link received" and "navigation dispatched + // / handled". + 'deeplink.dispatch_delay_ms': Math.max(0, Date.now() - link.receivedAtMs), + }); + return true; +} + +/** Returns true if the span is still recording (has not been ended). */ +function isSpanRecording(span: Span): boolean { + return spanToJSON(span).timestamp === undefined; +} + /** * Extracts the route name from a React Navigation dispatch action payload. * diff --git a/packages/core/test/tracing/pendingDeepLink.test.ts b/packages/core/test/tracing/pendingDeepLink.test.ts new file mode 100644 index 0000000000..d97f6a9d68 --- /dev/null +++ b/packages/core/test/tracing/pendingDeepLink.test.ts @@ -0,0 +1,111 @@ +import { + clearPendingDeepLink, + consumePendingDeepLink, + setPendingDeepLink, + setPendingDeepLinkListener, +} from '../../src/js/tracing/pendingDeepLink'; + +describe('pendingDeepLink', () => { + afterEach(() => { + clearPendingDeepLink(); + }); + + it('returns undefined when no deep link has been set', () => { + expect(consumePendingDeepLink(1_000)).toBeUndefined(); + }); + + it('returns the most recently stored URL with a receive timestamp', () => { + const before = Date.now(); + setPendingDeepLink('myapp://profile/123', 'warm-open'); + const after = Date.now(); + + const pending = consumePendingDeepLink(1_000); + expect(pending?.url).toBe('myapp://profile/123'); + expect(pending?.receivedAtMs).toBeGreaterThanOrEqual(before); + expect(pending?.receivedAtMs).toBeLessThanOrEqual(after); + }); + + it('clears the value after a single consume', () => { + setPendingDeepLink('myapp://a', 'warm-open'); + expect(consumePendingDeepLink(1_000)?.url).toBe('myapp://a'); + expect(consumePendingDeepLink(1_000)).toBeUndefined(); + }); + + it('overwrites a previous pending value', () => { + setPendingDeepLink('myapp://old', 'warm-open'); + setPendingDeepLink('myapp://new', 'warm-open'); + expect(consumePendingDeepLink(1_000)?.url).toBe('myapp://new'); + }); + + it('drops values older than maxAgeMs and still clears the slot', () => { + const originalNow = Date.now; + const baseNow = originalNow(); + Date.now = (): number => baseNow; + setPendingDeepLink('myapp://stale', 'warm-open'); + Date.now = (): number => baseNow + 5_000; + + try { + expect(consumePendingDeepLink(1_000)).toBeUndefined(); + // Slot must be empty even though the value was rejected. + Date.now = originalNow; + expect(consumePendingDeepLink(1_000)).toBeUndefined(); + } finally { + Date.now = originalNow; + } + }); + + it('clearPendingDeepLink removes the value without returning it', () => { + setPendingDeepLink('myapp://x', 'warm-open'); + clearPendingDeepLink(); + expect(consumePendingDeepLink(1_000)).toBeUndefined(); + }); + + describe('listener', () => { + it('is invoked synchronously on every set, with url + timestamp', () => { + const received: Array<{ url: string; receivedAtMs: number }> = []; + setPendingDeepLinkListener(link => { + received.push({ url: link.url, receivedAtMs: link.receivedAtMs }); + return false; + }); + + setPendingDeepLink('myapp://a', 'warm-open'); + setPendingDeepLink('myapp://b', 'warm-open'); + + expect(received.map(r => r.url)).toEqual(['myapp://a', 'myapp://b']); + expect(received[0]?.receivedAtMs).toBeGreaterThan(0); + }); + + it('skips storage when the listener returns true (already consumed)', () => { + setPendingDeepLinkListener(() => true); + setPendingDeepLink('myapp://consumed-by-listener', 'warm-open'); + + expect(consumePendingDeepLink(1_000)).toBeUndefined(); + }); + + it('falls through to storage when the listener returns false', () => { + setPendingDeepLinkListener(() => false); + setPendingDeepLink('myapp://stored', 'warm-open'); + + expect(consumePendingDeepLink(1_000)?.url).toBe('myapp://stored'); + }); + + it('can be unregistered with undefined', () => { + const fn = jest.fn().mockReturnValue(true); + setPendingDeepLinkListener(fn); + setPendingDeepLinkListener(undefined); + + setPendingDeepLink('myapp://x', 'warm-open'); + expect(fn).not.toHaveBeenCalled(); + expect(consumePendingDeepLink(1_000)?.url).toBe('myapp://x'); + }); + + it('clearPendingDeepLink also removes the listener', () => { + const fn = jest.fn().mockReturnValue(true); + setPendingDeepLinkListener(fn); + clearPendingDeepLink(); + + setPendingDeepLink('myapp://x', 'warm-open'); + expect(fn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 8e663e0a1c..86bb040b7c 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -14,6 +14,7 @@ import type { NavigationRoute } from '../../src/js/tracing/reactnavigation'; import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from '../../src/js/tracing/origin'; +import { clearPendingDeepLink, setPendingDeepLink } from '../../src/js/tracing/pendingDeepLink'; import { clearPendingExpoRouterNavigation, setPendingExpoRouterNavigation, @@ -312,6 +313,296 @@ describe('ReactNavigationInstrumentation', () => { expect(client.event?.contexts?.trace?.data?.['navigation.method']).toBeUndefined(); }); + test('deep-link hand-off | attaches trigger + sanitized URL on warm open', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + setPendingDeepLink('myapp://profile/123?token=secret', 'warm-open'); + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + const data = client.event?.contexts?.trace?.data ?? {}; + expect(data['navigation.trigger']).toBe('deeplink'); + // sendDefaultPii defaults to false → query stripped, numeric id replaced. + expect(data['deeplink.url']).toBe('myapp://profile/'); + expect(typeof data['deeplink.dispatch_delay_ms']).toBe('number'); + expect(data['deeplink.dispatch_delay_ms']).toBeGreaterThanOrEqual(0); + + clearPendingDeepLink(); + }); + + test('deep-link hand-off | keeps raw URL when sendDefaultPii is enabled', async () => { + setupTestClient({ sendDefaultPii: true }); + jest.runOnlyPendingTimers(); + + setPendingDeepLink('myapp://profile/123?token=secret', 'warm-open'); + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + expect(client.event?.contexts?.trace?.data?.['deeplink.url']).toBe('myapp://profile/123?token=secret'); + + clearPendingDeepLink(); + }); + + test('deep-link hand-off | does not attach when no deep link is pending', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); + + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + + const data = client.event?.contexts?.trace?.data ?? {}; + expect(data['navigation.trigger']).toBeUndefined(); + expect(data['deeplink.url']).toBeUndefined(); + }); + + test('deep-link hand-off | drops a deep link older than routeChangeTimeoutMs', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); + + // Forge an expired pending value (routeChangeTimeoutMs defaults to 1000ms). + const originalNow = Date.now; + const baseNow = originalNow(); + Date.now = (): number => baseNow; + setPendingDeepLink('myapp://stale', 'warm-open'); + Date.now = (): number => baseNow + 5_000; + + try { + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + + expect(client.event?.contexts?.trace?.data?.['navigation.trigger']).toBeUndefined(); + } finally { + Date.now = originalNow; + clearPendingDeepLink(); + } + }); + + test('deep-link hand-off | cold-start late arrival tags the initial finalized nav span while still recording', async () => { + // Reproduces Cursor bugbot's "late deep link never tags span" finding + // for the Expo Router cold-start case: Expo Router auto-handles + // `Linking.getInitialURL()` and the initial route mounts BEFORE our + // integration's own `getInitialURL()` chain resolves. The synchronous + // listener must attribute the link to the just-mounted-but-still- + // recording initial nav span. + setupTestClient(); + // Do NOT flush the init transaction — we want the initial nav span to + // still be inside its idle window when the cold-start link arrives. + + // Simulate the initial route mount (Expo Router would do this on + // container mount, before our deeplink integration sees the URL). + mockNavigation.finishAppStartNavigation(); + + // Cold-start link arrives LATE. + setPendingDeepLink('myapp://profile/123', 'cold-start'); + + jest.runOnlyPendingTimers(); // Flush the transaction. + await client.flush(); + + const data = client.event?.contexts?.trace?.data ?? {}; + expect(data['navigation.trigger']).toBe('deeplink'); + expect(data['deeplink.url']).toBe('myapp://profile/'); + + clearPendingDeepLink(); + }); + + test('deep-link hand-off | warm-open never tags an unrelated in-flight span (dispatched but not yet finalized)', async () => { + // Reproduces Cursor bugbot's "warm-open tags unrelated in-flight span" + // finding. If the user has just dispatched an unrelated navigation + // (state change pending) and a warm-open link arrives before the link + // handler has run, the listener must NOT attach the link to the + // in-flight span — it belongs to the (yet-to-be-dispatched) + // link-triggered navigation that follows. + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction. + + // Unrelated dispatch — `latestNavigationSpan` is now set, state change + // has not fired yet. + mockNavigation.emitNavigationWithoutStateChange(); + + // Warm-open link arrives mid-flight. + setPendingDeepLink('myapp://profile/789', 'warm-open'); + + // The unrelated dispatch finalizes (different route, not the link's). + mockNavigation.completeStateChangeToNewScreen(); + // The actual link-triggered navigation follows. + mockNavigation.navigateToSecondScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + + // The unrelated nav must NOT carry the deeplink trigger. + const newScreen = client.eventQueue.find(e => e.transaction === 'New Screen'); + expect(newScreen?.contexts?.trace?.data?.['navigation.trigger']).toBeUndefined(); + // The link-triggered nav must carry it. + const secondScreen = client.eventQueue.find(e => e.transaction === 'Second Screen'); + expect(secondScreen?.contexts?.trace?.data?.['navigation.trigger']).toBe('deeplink'); + expect(secondScreen?.contexts?.trace?.data?.['deeplink.url']).toBe('myapp://profile/'); + + clearPendingDeepLink(); + }); + + test('deep-link hand-off | warm-open never tags the previous navigation span', async () => { + // Reproduces Warden bot's "warm-open deep link can be consumed by a + // stale prior navigation span" finding. A warm-open link must NEVER + // attach to an already-finalized previous navigation — even if that + // span is still inside its idle window. It must wait in the pending + // slot for the navigation it actually triggers. + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction. + + // User navigates to screen A. State change finalizes the span, but it + // keeps recording until idle timeout. + mockNavigation.navigateToNewScreen(); + + // Warm-open link arrives WHILE spanA is still recording. + setPendingDeepLink('myapp://profile/456', 'warm-open'); + + // The next dispatch is the navigation actually triggered by the link. + // Done synchronously so the staleness-window timer does not advance + // fake `Date.now()` past `routeChangeTimeoutMs`. + mockNavigation.navigateToSecondScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + + // spanB (the link-triggered navigation) gets the deeplink attributes. + const spanBEvent = client.eventQueue.find(e => e.transaction === 'Second Screen'); + expect(spanBEvent?.contexts?.trace?.data?.['navigation.trigger']).toBe('deeplink'); + expect(spanBEvent?.contexts?.trace?.data?.['deeplink.url']).toBe('myapp://profile/'); + + // spanA (the *previous* navigation, still recording when the link + // arrived) MUST NOT carry the deeplink trigger. + const spanAEvent = client.eventQueue.find(e => e.transaction === 'New Screen'); + expect(spanAEvent?.contexts?.trace?.data?.['navigation.trigger']).toBeUndefined(); + expect(spanAEvent?.contexts?.trace?.data?.['deeplink.url']).toBeUndefined(); + + clearPendingDeepLink(); + }); + + test('deep-link hand-off | second link while a span is already tagged falls through to the pending slot', async () => { + // Reproduces Cursor bugbot's "late listener drops subsequent URLs" + // finding. If `tagSpanWithDeepLink` skips tagging (span already tagged), + // the listener must return false so the URL is stored for the next nav + // rather than being silently dropped. + setupTestClient(); + // Do NOT flush — keep the initial span recording. + + mockNavigation.finishAppStartNavigation(); + setPendingDeepLink('myapp://first', 'cold-start'); + // First link tagged the initial finalized span. A second link arriving + // immediately after must NOT be lost — the listener should report + // "not consumed" so the value is stored. + setPendingDeepLink('myapp://second', 'cold-start'); + + // Synchronously dispatch the next navigation so the pending value is + // consumed before the staleness-window timer advances fake time. + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + + const spanBEvent = client.eventQueue.find(e => e.transaction === 'New Screen'); + expect(spanBEvent?.contexts?.trace?.data?.['deeplink.url']).toBe('myapp://second'); + + clearPendingDeepLink(); + }); + + test('deep-link hand-off | discarded dispatch does not consume the pending link', async () => { + // Reproduces Cursor bugbot's "early consume loses pending link" finding. + // A dispatch may turn out to be a noop (no state change → timeout → + // discard). The pending link must survive that discard so the *next* + // real navigation — the one actually caused by the link — still gets + // attributed. + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + setPendingDeepLink('myapp://profile/123', 'warm-open'); + + // First dispatch never produces a state change. The next dispatch will + // discard it synchronously (the "a transaction was detected that turned + // out to be a noop" branch), exercising the discard path *without* + // advancing fake time past the staleness window. + mockNavigation.emitCancelledNavigation(); + + // Second dispatch is the real navigation. It must still pick up the link. + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + + const data = client.event?.contexts?.trace?.data ?? {}; + expect(data['navigation.trigger']).toBe('deeplink'); + expect(data['deeplink.url']).toBe('myapp://profile/'); + + clearPendingDeepLink(); + }); + + test('deep-link hand-off | same-route state change still attributes the link', async () => { + // Reproduces Cursor bugbot's "same-route path skips deeplink retry" + // finding. Deep-linking to the screen you're already on is a legitimate + // case — the dispatch still happens and an idle span still starts, so + // the link must be attached even when the resulting route key matches + // the previous one. + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + setPendingDeepLink('myapp://initial', 'warm-open'); + mockNavigation.navigateToInitialScreen(); // same key as the just-flushed init + jest.runOnlyPendingTimers(); + await client.flush(); + + const data = client.event?.contexts?.trace?.data ?? {}; + // We don't care which event captures it (the same-route branch ends the + // span via the idle path), but the attribute MUST be present. + expect(data['navigation.trigger']).toBe('deeplink'); + + clearPendingDeepLink(); + }); + + test('deep-link hand-off | cold start: link arriving after span starts but before state change still attaches', async () => { + // Simulates cold start: the React Navigation idle span is started in + // `afterAllSetup`, then `Linking.getInitialURL()` resolves a microtask + // later, then the route state finally changes. We model this by + // emitting the dispatch (which creates the span), THEN setting the + // pending deep link, THEN letting the state change fire. + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.emitNavigationWithoutStateChange(); + setPendingDeepLink('myapp://profile/123', 'cold-start'); + mockNavigation.completeStateChangeToNewScreen(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + const data = client.event?.contexts?.trace?.data ?? {}; + expect(data['navigation.trigger']).toBe('deeplink'); + expect(data['deeplink.url']).toBe('myapp://profile/'); + + clearPendingDeepLink(); + }); + + test('deep-link hand-off | consumes the pending value exactly once', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); + + setPendingDeepLink('myapp://profile/123', 'warm-open'); + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + expect(client.event?.contexts?.trace?.data?.['navigation.trigger']).toBe('deeplink'); + + // The next navigation must not inherit the deep-link attribution. + mockNavigation.navigateToSecondScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + expect(client.event?.contexts?.trace?.data?.['navigation.trigger']).toBeUndefined(); + expect(client.event?.contexts?.trace?.data?.['deeplink.url']).toBeUndefined(); + }); + test('drains the pending value even when the listener short-circuits (no leak onto the next nav)', async () => { // Reproduces the bug flagged by sentry-bot/cursor-bot: with // `useDispatchedActionData: true`, a dispatched action without a route diff --git a/packages/core/test/tracing/reactnavigationutils.ts b/packages/core/test/tracing/reactnavigationutils.ts index 93ee6e3dab..48609d1109 100644 --- a/packages/core/test/tracing/reactnavigationutils.ts +++ b/packages/core/test/tracing/reactnavigationutils.ts @@ -82,6 +82,14 @@ export function createMockNavigationAndAttachTo(sut: ReturnType { mockedNavigationContained.listeners['__unsafe_action__'](navigationAction); }, + /** Completes the route transition to "New Screen" by firing the state listener only — pair with `emitNavigationWithoutStateChange()` to split dispatch and state change across separate steps. */ + completeStateChangeToNewScreen: () => { + mockedNavigationContained.currentRoute = { + key: 'new_screen', + name: 'New Screen', + }; + mockedNavigationContained.listeners['state']({}); + }, emitWithoutStateChange: (action: UnsafeAction) => { mockedNavigationContained.listeners['__unsafe_action__'](action); },