-
-
Notifications
You must be signed in to change notification settings - Fork 359
feat(tracing): Correlate deep links with the navigation they trigger #6264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
ff90614
a2c3a6a
9341912
d728832
2758a72
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about clearing pending only when it is defined?
Suggested change
|
||||||||||||||||||
| 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; | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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'; | ||
|
|
@@ -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; | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| }; | ||
|
Check warning on line 257 in packages/core/src/js/tracing/reactnavigation.ts
|
||
|
|
||
| let initialStateHandled: boolean = false; | ||
| let isSetupComplete: boolean = false; | ||
|
|
@@ -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. | ||
|
|
@@ -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
|
||
| if (ignoreEmptyBackNavigationTransactions) { | ||
| ignoreEmptyBackNavigation(getClient(), latestNavigationSpan); | ||
| } | ||
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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
|
||
|
|
||
| navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${routeName} mounted`); | ||
| navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK }); | ||
| navigationProcessingSpan?.end(stateChangedTimestamp); | ||
|
|
@@ -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. | ||
| */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. q: is this comment referring to the |
||
| /** | ||
| * 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
|
||
|
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. | ||
| * | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.