Skip to content

feat(tracing): Correlate deep links with the navigation they trigger#6264

Open
alwx wants to merge 5 commits into
mainfrom
alwx/features/deep-linking
Open

feat(tracing): Correlate deep links with the navigation they trigger#6264
alwx wants to merge 5 commits into
mainfrom
alwx/features/deep-linking

Conversation

@alwx

@alwx alwx commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 Description

Closes #6159 by linking deep-link arrival to the navigation transaction it triggers. Previously the deeplink breadcrumb and the React Navigation idle span lived on disconnected timelines — this PR bridges them so traces can show the full "URL received → navigation dispatched → screen mounted" story.

How it works

  • pendingDeepLink module — stores the raw URL + receive timestamp, mirroring the existing pendingExpoRouterNavigation hand-off pattern. Values older than routeChangeTimeoutMs (default 1000ms) are dropped, and consumption is single-shot so a stale link can't leak onto a later, unrelated navigation.
  • Producer — deeplinkIntegration writes to the pending slot on both cold start (Linking.getInitialURL()) and warm open ('url' event).
  • Consumer — reactNavigationIntegration reads it in two places:
    • on dispatch (warm open: link arrives → navigation dispatches)
    • on state change (cold start: getInitialURL resolves after the first idle span has already been started in afterAllSetup)

📝 Checklist

  • I added tests to verify changes
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • All tests passing
  • No breaking changes

🔮 Next steps

Bridges `deeplinkIntegration` and `reactNavigationIntegration` so the idle
navigation span started after a deep link arrival is tagged with the link
that caused it. Previously the two timelines were unconnected — the
breadcrumb recorded `Linking.getInitialURL()` / the `'url'` event, but the
resulting navigation span had no way to know it had been triggered by a
deep link, making it impossible to measure the gap between "URL received"
and "navigation dispatched".

Approach mirrors the existing `pendingExpoRouterNavigation` hand-off:

* New `tracing/pendingDeepLink.ts` stores the raw URL plus a wall-clock
  receive timestamp. `consumePendingDeepLink(maxAgeMs)` discards values
  older than `routeChangeTimeoutMs` (default 1000ms) and clears the slot
  in all cases so a stale link cannot leak onto a later, unrelated nav.
* `deeplinkIntegration` now calls `setPendingDeepLink` alongside its
  existing breadcrumb. The raw URL is stored — sanitization is deferred
  to attach time so `sendDefaultPii` is read at the right moment.
* `reactNavigationIntegration` consumes the pending value in two places:
  - In `startIdleNavigationSpan`, covering the warm-open path (link
    arrives, then navigation dispatches).
  - In `updateLatestNavigationSpanWithCurrentRoute`, covering the cold
    start path where `getInitialURL()` resolves *after* the initial
    idle span has already been started in `afterAllSetup`.
  Attachment is idempotent per span via a `deepLinkAppliedToLatestSpan`
  flag, reset on discard and after each successful route change.

When attached, the span gets:

* `navigation.trigger`: `'deeplink'`
* `deeplink.url`: sanitized via the existing `sanitizeDeepLinkUrl`
  (now exported from `integrations/deeplink.ts`), or raw when
  `sendDefaultPii` is enabled
* `deeplink.received_at`: ms elapsed between URL receipt and the
  moment the span is annotated — captures the dispatch delay

Tests cover warm open, cold-start ordering (span starts before pending
is set), single-consume semantics, PII gating, stale rejection, and the
"no deep link" baseline. Out of scope (and noted in the issue): wiring
the native TTID fallback start time to deep-link arrival instead of
navigation dispatch — that requires native-bridge plumbing.

Fixes #6159
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


  • feat(tracing): Correlate deep links with the navigation they trigger by alwx in #6264
  • fix(core): Exclude additional server-only modules from native bundles by antonis in #6263
  • refactor(android,ios): Use native SDK deserializers for User and Breadcrumb by antonis in #6261
  • chore(deps): update JavaScript SDK to v10.57.0 by github-actions in #6265
  • Turbo Modules crash time context by alwx in #6227
  • fix: resolve sentry-cli relative to react-native package by shawnthye-guru in #6242
  • chore(deps): bump getsentry/craft from 2.26.6 to 2.26.8 by dependabot in #6260
  • chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.26.6 to 2.26.8 by dependabot in #6259
  • chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 by dependabot in #6257
  • chore(deps): bump github/codeql-action from 4.36.0 to 4.36.2 by dependabot in #6258
  • chore(deps): update Cocoa SDK to v9.16.1 by github-actions in #6252
  • chore(deps): update Android SDK to v8.43.1 by github-actions in #6253
  • chore(deps): update Sentry Android Gradle Plugin to v6.10.0 by github-actions in #6255
  • feat(tracing): Wrap Expo Router push, replace, navigate, back, dismiss in addition to prefetch by alwx in #6221
  • chore(deps): bump jwt in /samples/react-native by antonis in #6251
  • feat(profiling): Add measurements to Android profiling by antonis in #6250
  • chore(deps): update CLI to v3.5.0 by github-actions in #6248
  • chore(deps): bump jwt from 2.9.3 to 2.10.3 in /samples/react-native-macos by dependabot in #6247
  • chore(deps): bump jwt from 2.10.2 to 2.10.3 in /performance-tests by dependabot in #6246
  • feat(android): Warn when Gradle resolves unsupported sentry-android version by antonis in #6238
  • chore(deps): update JavaScript SDK to v10.56.0 by github-actions in #6249
  • chore(ci): Pin all GitHub Actions to full commit SHAs by antonis in #6243
  • fix(tracing): Enable fetch instrumentation when expo/fetch is active by antonis in #6226
  • fix: Bump tmp to 0.2.7 to resolve path traversal vulnerability by antonis in #6233

Plus 4 more


🤖 This preview updates automatically when you update the PR.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor
Fails
🚫 Pull request is not ready for merge, please add the "ready-to-merge" label to the pull request
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 2758a72

Comment thread packages/core/src/js/integrations/deeplink.ts Outdated
Comment thread packages/core/src/js/tracing/reactnavigation.ts Outdated
Comment thread packages/core/src/js/tracing/reactnavigation.ts
Comment thread packages/core/src/js/tracing/reactnavigation.ts Outdated
Addresses three findings from Cursor Bugbot and one from Sentry's review
bot on #6264:

* **Late deep link never tags span** (Cursor, medium). When Expo Router
  auto-handles `Linking.getInitialURL()` and finishes the initial
  navigation *before* our integration's own `getInitialURL().then(...)`
  chain resolves, the URL was never attributed to the resulting span.
  Fix: `pendingDeepLink` now supports a synchronous listener that the
  navigation integration registers in `afterAllSetup`. When a link
  arrives, the listener tags the in-flight (`latestNavigationSpan`) or
  most-recent still-recording (`lastIdleNavSpan`) span directly. If
  nothing live exists, the link falls through to the slot for the next
  dispatched navigation. A WeakSet guards against double-tagging.

* **Same-route path skips deeplink retry** (Cursor, low). The early
  return in `updateLatestNavigationSpanWithCurrentRoute` for matching
  route keys (legitimate when deep-linking to the screen you're already
  on) bypassed the attribution call. Fix: consume + tag in that branch
  too, before the span reference is dropped.

* **Early consume loses pending link** (Cursor, medium). Consuming the
  pending value inside `startIdleNavigationSpan` meant a later discard
  (noop / timeout / empty route) silently wasted the link — the real
  link-driven navigation that followed would not be attributed. Fix:
  removed the eager consume on dispatch. Pending values are now only
  consumed in `updateLatestNavigationSpanWithCurrentRoute` (post route
  mount) or by the listener (live span). Discards never touch the slot.

* **`deeplink.received_at` semantically misleading** (Sentry, medium).
  The attribute stored a duration but its `_at` suffix conventionally
  denotes a timestamp. Renamed to `deeplink.dispatch_delay_ms`, which
  accurately reflects the measured gap between URL receipt and span
  annotation.

Plus: changelog entry now references PR #6264 (Danger gate).
Comment thread packages/core/src/js/tracing/reactnavigation.ts Outdated
Comment thread packages/core/src/js/tracing/reactnavigation.ts Outdated
Comment thread packages/core/src/js/integrations/deeplink.ts
Comment thread packages/core/src/js/integrations/deeplink.ts Outdated
Comment on lines +726 to +732
/**
* 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?

mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
},
/** Dispatches a NAVIGATE action targeting "New Screen" without changing route state. */
dispatchToNewScreen: () => {

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.

nit: though it's an oneliner we could use or call the identical emitNavigationWithoutStateChange function above

@antonis antonis left a comment

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.

Overall looks good. Left a few comments along with the agent feedback. We should also regenerate the api ref.

@lucas-zimerman lucas-zimerman left a comment

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.

No additional notes, worth checking the warden warnings that are not shown as comments and Antonis notes

…tion

Second review round. Addresses two further Cursor Bugbot findings, one
Warden bot finding, and three review nits from @antonis on #6264:

* **Warm-open deep link can be consumed by a stale prior nav span**
  (Warden, medium). The previous design tagged `lastIdleNavSpan` (the
  most recently created idle nav span) unconditionally when a link
  arrived after `latestNavigationSpan` was cleared. For warm opens that
  meant tagging the *previous, unrelated* navigation span (still inside
  its idle window) with `navigation.trigger=deeplink`, while the actual
  link-triggered navigation that followed got nothing.

  Fix: `pendingDeepLink` now carries a `source: 'cold-start' | 'warm-open'`
  flag set by `deeplinkIntegration` based on which Linking API surfaced
  the URL (`getInitialURL()` vs `'url'` event). The retroactive
  attribution path in the navigation integration is gated on
  `source === 'cold-start'` and targets a single, frozen reference
  (`initialFinalizedNavSpan` — the first nav span that ever finished
  its state change) rather than a rolling "most recent" pointer. Warm
  opens always go through the pending slot and attach to the next
  dispatched navigation.

* **Stale pending link not drained** (Cursor, medium).
  `applyPendingDeepLinkToSpan` previously early-returned without
  consuming the slot when the target span was already tagged (e.g. by
  the listener). An older link left in the slot would then attach to
  the next unrelated navigation. Fix: always consume the slot inside
  `applyPendingDeepLinkToSpan`. The function is now `void` and its only
  remaining responsibility is "drain pending, tag if appropriate".

* **Late listener drops subsequent URLs** (Cursor, low).
  `handleLateDeepLink` returned `true` whenever a recording span was
  reachable, even if `tagSpanWithDeepLink` skipped tagging because the
  span was already annotated. The pendingDeepLink module then treated
  the URL as consumed and dropped it. Fix: `tagSpanWithDeepLink` now
  returns whether it actually tagged; the listener propagates that
  value so an already-tagged span falls through to the pending slot.

Review nits:
* Duplicate JSDoc above `sanitizeDeepLinkUrl` merged into a single block.
* Orphaned JSDoc that had drifted above `taggedDeepLinkSpans` moved back
  onto `applyPendingDeepLinkToSpan` where it belongs.
* Removed the redundant `dispatchToNewScreen` test helper — the existing
  `emitNavigationWithoutStateChange` does the same thing and is now
  paired with `completeStateChangeToNewScreen` in the cold-start ordering
  test.

New tests:
* `cold-start late arrival tags the initial finalized nav span while still
  recording` — covers the Expo Router auto-handled cold-start case.
* `warm-open never tags the previous navigation span` — verifies Warden's
  bug is fixed.
* `second link while a span is already tagged falls through to the pending
  slot` — verifies the listener no longer drops follow-up URLs.

All 1558 SDK tests pass.
Comment thread packages/core/src/js/tracing/reactnavigation.ts
@alwx alwx requested review from antonis and lucas-zimerman June 10, 2026 08:02
Addresses the final Cursor Bugbot finding on #6264: "Warm-open tags
unrelated in-flight span".

Scenario: the user dispatches an unrelated navigation (state change
pending, `latestNavigationSpan = spanA`). While that span is mid-flight,
a warm-open deep link arrives. The previous design's listener already
skipped tagging `spanA` directly (cold-start gate added last round), but
the slot consumer in `updateLatestNavigationSpanWithCurrentRoute` had no
way to tell that the pending warm-open link did NOT cause `spanA`. The
state change would then attribute the link to `spanA`, while the actual
link-triggered navigation that followed got no attributes.

The fix introduces causality tracking via a shared monotonic counter:

* `pendingDeepLink` now stamps every recorded link with a `seq` drawn
  from `nextEventSeq()`. The same counter is incremented in the nav
  integration every time an idle nav span is created.
* Each freshly-created span is stored in a `WeakMap<Span, number>` with
  its dispatch seq.
* `applyPendingDeepLinkToSpan` peeks the slot before consuming. For
  warm-open links, it only attaches to a span whose dispatch seq is
  strictly greater than the link's seq (i.e. the span was dispatched
  *after* the link arrived, so it can plausibly be the link's target).
  Rejected warm-open links are left in the slot for the next eligible
  dispatch.
* Cold-start links keep their unconditional retroactive attribution
  semantics (the whole point of that source — Expo Router auto-handles
  the link before our `getInitialURL()` chain resolves).

`peekPendingDeepLink(maxAgeMs)` is a new export that mirrors
`consumePendingDeepLink` without clearing the slot, so the consumer can
inspect the seq before deciding whether to take the value.

A counter beats wall-clock timestamps for this purpose because Jest's
fake timers (and identical-tick events in real code) can produce
identical `Date.now()` values for events that are causally ordered. The
seq is monotonic across the whole module, so any pair of events has a
deterministic order regardless of test timing.

New test: `warm-open never tags an unrelated in-flight span (dispatched
but not yet finalized)` — exercises the exact scenario Cursor flagged.

All 1597 SDK tests pass.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2758a72. Configure here.

// the link before our own `getInitialURL()` chain resolved).
if (initialFinalizedNavSpan && isSpanRecording(initialFinalizedNavSpan)) {
return tagSpanWithDeepLink(initialFinalizedNavSpan, link);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Late cold-start tags wrong span

Medium Severity

handleLateDeepLink checks latestNavigationSpan before initialFinalizedNavSpan. When getInitialURL() resolves after the initial route is finalized but a later navigation is already in flight, a cold-start launch URL is attributed to that unrelated in-flight span instead of the initial idle span, contradicting the stated rule that cold-start links must not tag later navigations.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2758a72. Configure here.

}
}
consumePendingDeepLink(maxAgeMs);
tagSpanWithDeepLink(span, pending);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pending link cleared without tagging

Medium Severity

applyPendingDeepLinkToSpan calls consumePendingDeepLink before tagSpanWithDeepLink. If the span was already deeplink-tagged (for example a second cold-start URL while the first was applied via the listener), tagging is skipped but the pending entry is still cleared, so the URL is dropped and never attached to a later navigation.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2758a72. Configure here.

Comment thread CHANGELOG.md

### 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))

Comment on lines +114 to +117
pending = undefined;
if (!value) {
return undefined;
}

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;

@lucas-zimerman lucas-zimerman left a comment

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.

Looking good! I left a few nonblocking suggestions, thank you for the PR!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Link deep-link arrival to the resulting navigation transaction

3 participants