Skip to content

Commit ff90614

Browse files
committed
feat(tracing): Correlate deep links with the navigation they trigger
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
1 parent 7a89652 commit ff90614

8 files changed

Lines changed: 297 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
### Features
1212

13+
- 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.received_at` (ms gap between URL received and navigation dispatched). Covers both cold start (`Linking.getInitialURL()`) and warm open (`'url'` event) paths ([#6159](https://github.com/getsentry/sentry-react-native/issues/6159))
1314
- Add memory, CPU, and frame measurements to Android profiling ([#6250](https://github.com/getsentry/sentry-react-native/pull/6250))
1415
- 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))
1516
- 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))

packages/core/etc/sentry-react-native.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -857,7 +857,7 @@ export function wrapExpoRouter<T extends ExpoRouter>(router: T): T;
857857
// src/js/feedback/integration.ts:21:5 - (ae-forgotten-export) The symbol "ScreenshotButtonProps" needs to be exported by the entry point index.d.ts
858858
// src/js/feedback/integration.ts:23:5 - (ae-forgotten-export) The symbol "FeedbackFormTheme" needs to be exported by the entry point index.d.ts
859859
// src/js/tracing/reactnativetracing.ts:90:3 - (ae-forgotten-export) The symbol "ReactNativeTracingState" needs to be exported by the entry point index.d.ts
860-
// src/js/tracing/reactnavigation.ts:220:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts
860+
// src/js/tracing/reactnavigation.ts:222:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts
861861

862862
// (No @packageDocumentation comment for this package)
863863

packages/core/src/js/integrations/deeplink.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { IntegrationFn } from '@sentry/core';
22

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

5+
import { setPendingDeepLink } from '../tracing/pendingDeepLink';
56
import { sanitizeUrl } from '../tracing/utils';
67

78
export const INTEGRATION_NAME = 'DeepLink';
@@ -21,7 +22,14 @@ interface RNLinking {
2122
*
2223
* Only replaces segments that look like identifiers (all digits, UUIDs, or hex strings).
2324
*/
24-
function sanitizeDeepLinkUrl(url: string): string {
25+
/**
26+
* Replaces dynamic path segments (UUID-like or numeric values) with a placeholder
27+
* to avoid capturing PII in path segments when `sendDefaultPii` is off.
28+
*
29+
* Exported so the navigation integration can apply the same sanitization when
30+
* attaching a deep link URL to a navigation span.
31+
*/
32+
export function sanitizeDeepLinkUrl(url: string): string {
2533
const stripped = sanitizeUrl(url);
2634

2735
// Split off the scheme+authority (e.g. "myapp://host") so the regex
@@ -55,7 +63,13 @@ function getBreadcrumbUrl(url: string): string {
5563
return sendDefaultPii ? url : sanitizeDeepLinkUrl(url);
5664
}
5765

58-
function addDeepLinkBreadcrumb(url: string): void {
66+
function recordDeepLink(url: string): void {
67+
// Hand off to the navigation integration so the next idle navigation span
68+
// can attribute itself to this deep link. Always stores the raw URL —
69+
// sanitization (if any) happens at attach time, based on the client's
70+
// `sendDefaultPii` option at that moment.
71+
setPendingDeepLink(url);
72+
5973
const breadcrumbUrl = getBreadcrumbUrl(url);
6074
addBreadcrumb({
6175
category: 'deeplink',
@@ -87,7 +101,7 @@ const _deeplinkIntegration: IntegrationFn = () => {
87101
.getInitialURL()
88102
.then((url: string | null) => {
89103
if (url) {
90-
addDeepLinkBreadcrumb(url);
104+
recordDeepLink(url);
91105
}
92106
})
93107
.catch(() => {
@@ -97,7 +111,7 @@ const _deeplinkIntegration: IntegrationFn = () => {
97111
// Warm open: deep link received while app is running
98112
subscription = linking.addEventListener('url', (event: { url: string }) => {
99113
if (event?.url) {
100-
addDeepLinkBreadcrumb(event.url);
114+
recordDeepLink(event.url);
101115
}
102116
});
103117

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Cross-module hand-off between the {@link deeplinkIntegration} and the
3+
* {@link reactNavigationIntegration} idle navigation span.
4+
*
5+
* When a deep link is received (either via `Linking.getInitialURL()` on cold
6+
* start or via the `'url'` event on warm open), the integration stores the
7+
* raw URL together with a receive timestamp here. The navigation integration
8+
* then attaches it to the next idle navigation span started within
9+
* `routeChangeTimeoutMs` (default 1000ms), so traces can correlate
10+
* "deep link → navigation" timing.
11+
*/
12+
13+
export interface PendingDeepLink {
14+
/** Raw URL as received from React Native's `Linking` API. */
15+
url: string;
16+
/** Wall-clock timestamp (ms since epoch) when the URL was received. */
17+
receivedAtMs: number;
18+
}
19+
20+
let pending: PendingDeepLink | undefined;
21+
22+
/**
23+
* Stores the most recently received deep link URL together with the current
24+
* timestamp. Overwrites any previous pending value — only the latest link
25+
* matters for correlation with the next navigation.
26+
*/
27+
export function setPendingDeepLink(url: string): void {
28+
pending = { url, receivedAtMs: Date.now() };
29+
}
30+
31+
/**
32+
* Returns and clears the pending deep link, but only if it was received
33+
* within `maxAgeMs` of "now". Stale entries are discarded.
34+
*/
35+
export function consumePendingDeepLink(maxAgeMs: number): PendingDeepLink | undefined {
36+
const value = pending;
37+
pending = undefined;
38+
if (!value) {
39+
return undefined;
40+
}
41+
if (Date.now() - value.receivedAtMs > maxAgeMs) {
42+
return undefined;
43+
}
44+
return value;
45+
}
46+
47+
/** Test helper — clears the pending value without consuming it. */
48+
export function clearPendingDeepLink(): void {
49+
pending = undefined;
50+
}

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { UnsafeAction } from '../vendor/react-navigation/types';
1818
import type { ReactNativeTracingIntegration } from './reactnativetracing';
1919

2020
import { getAppRegistryIntegration } from '../integrations/appRegistry';
21+
import { sanitizeDeepLinkUrl } from '../integrations/deeplink';
2122
import { isSentrySpan } from '../utils/span';
2223
import { RN_GLOBAL_OBJ } from '../utils/worldwide';
2324
import { NATIVE } from '../wrapper';
@@ -27,6 +28,7 @@ import {
2728
markRootSpanForDiscard,
2829
} from './onSpanEndUtils';
2930
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin';
31+
import { consumePendingDeepLink } from './pendingDeepLink';
3032
import { consumePendingExpoRouterNavigation } from './pendingExpoRouterNavigation';
3133
import { getReactNativeTracingIntegration } from './reactnativetracing';
3234
import { SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes';
@@ -230,6 +232,7 @@ export const reactNavigationIntegration = ({
230232
let latestNavigationSpan: Span | undefined;
231233
let latestNavigationSpanNameCustomized: boolean = false;
232234
let navigationProcessingSpan: Span | undefined;
235+
let deepLinkAppliedToLatestSpan: boolean = false;
233236

234237
let initialStateHandled: boolean = false;
235238
let isSetupComplete: boolean = false;
@@ -463,6 +466,15 @@ export const reactNavigationIntegration = ({
463466
if (pendingExpoRouter && latestNavigationSpan) {
464467
latestNavigationSpan.setAttribute('navigation.method', pendingExpoRouter.method);
465468
}
469+
470+
// Try to attribute this span to a recently received deep link. Covers the
471+
// warm-open case (link arrives → navigation dispatches). The cold-start
472+
// case is handled again in `updateLatestNavigationSpanWithCurrentRoute`
473+
// because `Linking.getInitialURL()` may resolve after this point.
474+
deepLinkAppliedToLatestSpan = false;
475+
if (latestNavigationSpan) {
476+
deepLinkAppliedToLatestSpan = applyPendingDeepLinkToSpan(latestNavigationSpan, routeChangeTimeoutMs);
477+
}
466478
if (ignoreEmptyBackNavigationTransactions) {
467479
ignoreEmptyBackNavigation(getClient(), latestNavigationSpan);
468480
}
@@ -555,6 +567,13 @@ export const reactNavigationIntegration = ({
555567
routeName = getPathFromState(navigationState) || route.name;
556568
}
557569

570+
// Cold-start fallback: if the deep link arrived *after* the idle navigation
571+
// span was started (e.g. `getInitialURL()` resolved post-`afterAllSetup`),
572+
// try again now that the route has mounted.
573+
if (!deepLinkAppliedToLatestSpan) {
574+
deepLinkAppliedToLatestSpan = applyPendingDeepLinkToSpan(latestNavigationSpan, routeChangeTimeoutMs);
575+
}
576+
558577
navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${routeName} mounted`);
559578
navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK });
560579
navigationProcessingSpan?.end(stateChangedTimestamp);
@@ -600,6 +619,7 @@ export const reactNavigationIntegration = ({
600619
}
601620
// Clear the latest transaction as it has been handled.
602621
latestNavigationSpan = undefined;
622+
deepLinkAppliedToLatestSpan = false;
603623
};
604624

605625
/** Pushes a recent route key, and removes earlier routes when there is greater than the max length */
@@ -624,6 +644,7 @@ export const reactNavigationIntegration = ({
624644
if (navigationProcessingSpan) {
625645
navigationProcessingSpan = undefined;
626646
}
647+
deepLinkAppliedToLatestSpan = false;
627648
};
628649

629650
const clearStateChangeTimeout = (): void => {
@@ -673,6 +694,30 @@ interface NavigationContainer {
673694
getState: () => NavigationState | undefined;
674695
}
675696

697+
/**
698+
* Attempts to consume the pending deep link and attach it to the given span.
699+
*
700+
* Returns `true` when a deep link was consumed (and the span was annotated),
701+
* `false` otherwise. Callers may invoke this multiple times against the same
702+
* span — once the pending value has been consumed it will not be re-applied.
703+
*/
704+
function applyPendingDeepLinkToSpan(span: Span, maxAgeMs: number): boolean {
705+
const pending = consumePendingDeepLink(maxAgeMs);
706+
if (!pending) {
707+
return false;
708+
}
709+
710+
const sendDefaultPii = getClient()?.getOptions()?.sendDefaultPii ?? false;
711+
const url = sendDefaultPii ? pending.url : sanitizeDeepLinkUrl(pending.url);
712+
713+
span.setAttributes({
714+
'navigation.trigger': 'deeplink',
715+
'deeplink.url': url,
716+
'deeplink.received_at': Math.max(0, Date.now() - pending.receivedAtMs),
717+
});
718+
return true;
719+
}
720+
676721
/**
677722
* Extracts the route name from a React Navigation dispatch action payload.
678723
*
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { clearPendingDeepLink, consumePendingDeepLink, setPendingDeepLink } from '../../src/js/tracing/pendingDeepLink';
2+
3+
describe('pendingDeepLink', () => {
4+
afterEach(() => {
5+
clearPendingDeepLink();
6+
});
7+
8+
it('returns undefined when no deep link has been set', () => {
9+
expect(consumePendingDeepLink(1_000)).toBeUndefined();
10+
});
11+
12+
it('returns the most recently stored URL with a receive timestamp', () => {
13+
const before = Date.now();
14+
setPendingDeepLink('myapp://profile/123');
15+
const after = Date.now();
16+
17+
const pending = consumePendingDeepLink(1_000);
18+
expect(pending?.url).toBe('myapp://profile/123');
19+
expect(pending?.receivedAtMs).toBeGreaterThanOrEqual(before);
20+
expect(pending?.receivedAtMs).toBeLessThanOrEqual(after);
21+
});
22+
23+
it('clears the value after a single consume', () => {
24+
setPendingDeepLink('myapp://a');
25+
expect(consumePendingDeepLink(1_000)?.url).toBe('myapp://a');
26+
expect(consumePendingDeepLink(1_000)).toBeUndefined();
27+
});
28+
29+
it('overwrites a previous pending value', () => {
30+
setPendingDeepLink('myapp://old');
31+
setPendingDeepLink('myapp://new');
32+
expect(consumePendingDeepLink(1_000)?.url).toBe('myapp://new');
33+
});
34+
35+
it('drops values older than maxAgeMs and still clears the slot', () => {
36+
const originalNow = Date.now;
37+
const baseNow = originalNow();
38+
Date.now = (): number => baseNow;
39+
setPendingDeepLink('myapp://stale');
40+
Date.now = (): number => baseNow + 5_000;
41+
42+
try {
43+
expect(consumePendingDeepLink(1_000)).toBeUndefined();
44+
// Slot must be empty even though the value was rejected.
45+
Date.now = originalNow;
46+
expect(consumePendingDeepLink(1_000)).toBeUndefined();
47+
} finally {
48+
Date.now = originalNow;
49+
}
50+
});
51+
52+
it('clearPendingDeepLink removes the value without returning it', () => {
53+
setPendingDeepLink('myapp://x');
54+
clearPendingDeepLink();
55+
expect(consumePendingDeepLink(1_000)).toBeUndefined();
56+
});
57+
});

0 commit comments

Comments
 (0)