Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

@lucas-zimerman lucas-zimerman Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of shorting the changelog? Users can see more details about it when opening the PR URL.

Suggested change
- 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))
- Correlate deep links with the navigation they trigger, tagging the resulting transaction with `navigation.trigger: 'deeplink'`, the deep
link URL (sanitized unless `sendDefaultPii` is enabled), and the time between link arrival and navigation. Works for both cold start and
warm app launches ([#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))
Expand Down
2 changes: 1 addition & 1 deletion packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@
// 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:224:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts

Check warning on line 860 in packages/core/etc/sentry-react-native.api.md

View check run for this annotation

@sentry/warden / warden: code-review

handleLateDeepLink can tag a previous navigation's idle span on warm open, leaving the actual triggered span untagged

In `reactnavigation.ts` `handleLateDeepLink` (line 250), when a warm-open deep link arrives while a previous navigation's idle span is still recording (`lastIdleNavSpan` not yet ended), the listener tags that unrelated span and returns `true`, preventing the link from being stored in the pending slot โ€” so the navigation span actually triggered by the deep link never receives the `navigation.trigger`/`deeplink.url` attributes. Consider adding a staleness guard (e.g. comparing `link.receivedAtMs` against the span's start timestamp) before tagging via the listener path.

// (No @packageDocumentation comment for this package)

Expand Down
22 changes: 18 additions & 4 deletions packages/core/src/js/integrations/deeplink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { addBreadcrumb, defineIntegration, getClient } from '@sentry/core';

import { setPendingDeepLink } from '../tracing/pendingDeepLink';
import { sanitizeUrl } from '../tracing/utils';

export const INTEGRATION_NAME = 'DeepLink';
Expand All @@ -21,7 +22,14 @@
*
* Only replaces segments that look like identifiers (all digits, UUIDs, or hex strings).
*/
Comment thread
lucas-zimerman marked this conversation as resolved.
Outdated
function sanitizeDeepLinkUrl(url: string): string {
/**
* Replaces dynamic path segments (UUID-like or numeric values) with a placeholder
* to avoid capturing PII in path segments when `sendDefaultPii` is off.
*
* Exported so the navigation integration can apply the same sanitization when
* attaching a deep link URL to a navigation span.
*/
export function sanitizeDeepLinkUrl(url: string): string {
const stripped = sanitizeUrl(url);

// Split off the scheme+authority (e.g. "myapp://host") so the regex
Expand Down Expand Up @@ -55,7 +63,13 @@
return sendDefaultPii ? url : sanitizeDeepLinkUrl(url);
}

function addDeepLinkBreadcrumb(url: string): void {
function recordDeepLink(url: string): 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);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

const breadcrumbUrl = getBreadcrumbUrl(url);
addBreadcrumb({
category: 'deeplink',
Expand Down Expand Up @@ -87,18 +101,18 @@
.getInitialURL()
.then((url: string | null) => {
if (url) {
addDeepLinkBreadcrumb(url);
recordDeepLink(url);
}
})
.catch(() => {
// Ignore errors from getInitialURL
});

// 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);
}

Check warning on line 115 in packages/core/src/js/integrations/deeplink.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

Warm-open deep link can be consumed by a stale prior navigation span, leaving the triggered navigation span untagged

`handleLateDeepLink` in `reactnavigation.ts` is invoked synchronously for every deep link via the registered pendingDeepLink listener, including warm-open `url` events. It picks `latestNavigationSpan ?? lastIdleNavSpan`. After a completed navigation `latestNavigationSpan` is `undefined`, so it falls back to `lastIdleNavSpan`, which is never cleared and keeps pointing at the previous screen's idle span. If that span is still recording (within its 1s idle timeout), the listener tags the *previous* span and returns `true`, so `setPendingDeepLink` never stores the URL. The navigation actually triggered by the deep link is then created moments later and finds nothing to consume in `updateLatestNavigationSpanWithCurrentRoute`, leaving it untagged while the wrong span carries `navigation.trigger=deeplink`.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
});

client.on('close', () => {
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/js/tracing/pendingDeepLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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.
*/

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;
}

/**
* 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;

/**
* 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): void {
const value: PendingDeepLink = { url, receivedAtMs: Date.now() };
if (listener?.(value)) {
return;
}
pending = value;
}

/**
* 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;
}
Comment on lines +114 to +117

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about clearing pending only when it is defined?

Suggested change
pending = undefined;
if (!value) {
return undefined;
}
if (!value) {
return undefined;
}
pending = 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 and listener without consuming them. */
export function clearPendingDeepLink(): void {
pending = undefined;
listener = undefined;
}
103 changes: 103 additions & 0 deletions packages/core/src/js/tracing/reactnavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
} 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';
Expand All @@ -27,6 +29,7 @@
markRootSpanForDiscard,
} from './onSpanEndUtils';
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin';
import { consumePendingDeepLink, setPendingDeepLinkListener } from './pendingDeepLink';
import { consumePendingExpoRouterNavigation } from './pendingExpoRouterNavigation';
import { getReactNativeTracingIntegration } from './reactnativetracing';
import { SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes';
Expand Down Expand Up @@ -230,6 +233,28 @@
let latestNavigationSpan: Span | undefined;
let latestNavigationSpanNameCustomized: boolean = false;
let navigationProcessingSpan: Span | undefined;
/**
* Most recently created idle navigation span, retained even after
* `latestNavigationSpan` is cleared on state change. Used by the deep-link
* listener to attribute a link that arrives between "route mounted" and
* "idle span ended". Cleared when superseded by the next nav span.
*/
let lastIdleNavSpan: Span | undefined;

/**
* Synchronous listener invoked the moment a deep link is recorded. If a live
* idle navigation span exists, tag it directly and tell the pendingDeepLink
* module that the link has been consumed โ€” otherwise let the link fall through
* to the slot so the next dispatched navigation picks it up.
*/
const handleLateDeepLink = (link: PendingDeepLink): boolean => {
const span = latestNavigationSpan ?? lastIdleNavSpan;
if (!span || !isSpanRecording(span)) {
return false;
}
tagSpanWithDeepLink(span, link);
return true;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
};

Check warning on line 257 in packages/core/src/js/tracing/reactnavigation.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[FBE-RE4] Warm-open deep link can be consumed by a stale prior navigation span, leaving the triggered navigation span untagged (additional location)

`handleLateDeepLink` in `reactnavigation.ts` is invoked synchronously for every deep link via the registered pendingDeepLink listener, including warm-open `url` events. It picks `latestNavigationSpan ?? lastIdleNavSpan`. After a completed navigation `latestNavigationSpan` is `undefined`, so it falls back to `lastIdleNavSpan`, which is never cleared and keeps pointing at the previous screen's idle span. If that span is still recording (within its 1s idle timeout), the listener tags the *previous* span and returns `true`, so `setPendingDeepLink` never stores the URL. The navigation actually triggered by the deep link is then created moments later and finds nothing to consume in `updateLatestNavigationSpanWithCurrentRoute`, leaving it untagged while the wrong span carries `navigation.trigger=deeplink`.

let initialStateHandled: boolean = false;
let isSetupComplete: boolean = false;
Expand All @@ -254,6 +279,14 @@
};
}

// 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.
Expand Down Expand Up @@ -463,6 +496,13 @@
if (pendingExpoRouter && latestNavigationSpan) {
latestNavigationSpan.setAttribute('navigation.method', pendingExpoRouter.method);
}

// Hold a reference to the freshly-created idle span so the deep-link
// listener can still annotate it after `latestNavigationSpan` is cleared
// on state change. 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.
lastIdleNavSpan = latestNavigationSpan;

Check warning on line 505 in packages/core/src/js/tracing/reactnavigation.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[FBE-RE4] Warm-open deep link can be consumed by a stale prior navigation span, leaving the triggered navigation span untagged (additional location)

`handleLateDeepLink` in `reactnavigation.ts` is invoked synchronously for every deep link via the registered pendingDeepLink listener, including warm-open `url` events. It picks `latestNavigationSpan ?? lastIdleNavSpan`. After a completed navigation `latestNavigationSpan` is `undefined`, so it falls back to `lastIdleNavSpan`, which is never cleared and keeps pointing at the previous screen's idle span. If that span is still recording (within its 1s idle timeout), the listener tags the *previous* span and returns `true`, so `setPendingDeepLink` never stores the URL. The navigation actually triggered by the deep link is then created moments later and finds nothing to consume in `updateLatestNavigationSpanWithCurrentRoute`, leaving it untagged while the wrong span carries `navigation.trigger=deeplink`.
if (ignoreEmptyBackNavigationTransactions) {
ignoreEmptyBackNavigation(getClient(), latestNavigationSpan);
}
Expand Down Expand Up @@ -519,6 +559,11 @@

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;

Expand Down Expand Up @@ -555,6 +600,11 @@
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);

Check warning on line 606 in packages/core/src/js/tracing/reactnavigation.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[4Z3-NVS] Stale pending deep link leaks to the wrong navigation span when `handleLateDeepLink` tags a span (additional location)

When `applyPendingDeepLinkToSpan` short-circuits because the span is already in `taggedDeepLinkSpans`, it returns early without calling `consumePendingDeepLink`, leaving any previously-stored pending link in the slot. A subsequent navigation's `applyPendingDeepLinkToSpan` call then consumes and applies the stale link, attributing the wrong URL to an unrelated span.

navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${routeName} mounted`);
navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK });
navigationProcessingSpan?.end(stateChangedTimestamp);
Expand Down Expand Up @@ -673,6 +723,59 @@
getState: () => NavigationState | undefined;
}

/**
* Attempts to consume the pending deep link and attach it to the given span.
*
* Returns `true` when a deep link was consumed (and the span was annotated),
* `false` otherwise. Callers may invoke this multiple times against the same
* span โ€” once the pending value has been consumed it will not be re-applied.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: is this comment referring to the applyPendingDeepLinkToSpan function below?

/**
* 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<Span>();

/**
* Annotates the given span with deep-link attributes if it has not already
* been annotated. Safe to call multiple times โ€” a span is tagged at most once.
*/
function tagSpanWithDeepLink(span: Span, link: PendingDeepLink): void {
if (taggedDeepLinkSpans.has(span)) {
return;
}
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),
});
}

/** Returns true if the span is still recording (has not been ended). */
function isSpanRecording(span: Span): boolean {
return spanToJSON(span).timestamp === undefined;
}

function applyPendingDeepLinkToSpan(span: Span, maxAgeMs: number): boolean {
if (taggedDeepLinkSpans.has(span)) {
return true;
}

Check warning on line 770 in packages/core/src/js/tracing/reactnavigation.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

Stale pending deep link leaks to the wrong navigation span when `handleLateDeepLink` tags a span

When `applyPendingDeepLinkToSpan` short-circuits because the span is already in `taggedDeepLinkSpans`, it returns early without calling `consumePendingDeepLink`, leaving any previously-stored pending link in the slot. A subsequent navigation's `applyPendingDeepLinkToSpan` call then consumes and applies the stale link, attributing the wrong URL to an unrelated span.
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
const pending = consumePendingDeepLink(maxAgeMs);
if (!pending) {
return false;
}
tagSpanWithDeepLink(span, pending);
return true;
}

/**
* Extracts the route name from a React Navigation dispatch action payload.
*
Expand Down
Loading
Loading