Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.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))
- 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 @@ export function wrapExpoRouter<T extends ExpoRouter>(router: T): T;
// 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:222:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts

// (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 type { IntegrationFn } from '@sentry/core';

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 @@ interface RNLinking {
*
* 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 @@ function getBreadcrumbUrl(url: string): string {
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,7 +101,7 @@ const _deeplinkIntegration: IntegrationFn = () => {
.getInitialURL()
.then((url: string | null) => {
if (url) {
addDeepLinkBreadcrumb(url);
recordDeepLink(url);
}
})
.catch(() => {
Expand All @@ -97,7 +111,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);
}
Comment thread
sentry-warden[bot] marked this conversation as resolved.
});

Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/js/tracing/pendingDeepLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Cross-module hand-off between the {@link deeplinkIntegration} and the
* {@link reactNavigationIntegration} idle navigation span.
*
* When a deep link is received (either via `Linking.getInitialURL()` on cold
* start or via the `'url'` event on warm open), the integration stores the
* raw URL together with a receive timestamp here. The navigation integration
* then attaches it to the next idle navigation span started within
* `routeChangeTimeoutMs` (default 1000ms), so traces can correlate
* "deep link โ†’ navigation" timing.
*/

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

let pending: PendingDeepLink | undefined;

/**
* Stores the most recently received deep link URL together with the current
* timestamp. Overwrites any previous pending value โ€” only the latest link
* matters for correlation with the next navigation.
*/
export function setPendingDeepLink(url: string): void {
pending = { url, receivedAtMs: Date.now() };
}

/**
* Returns and clears the pending deep link, but only if it was received
* within `maxAgeMs` of "now". Stale entries are discarded.
*/
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;
}

/** Test helper โ€” clears the pending value without consuming it. */
export function clearPendingDeepLink(): void {
pending = undefined;
}
45 changes: 45 additions & 0 deletions packages/core/src/js/tracing/reactnavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { UnsafeAction } from '../vendor/react-navigation/types';
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 +28,7 @@ import {
markRootSpanForDiscard,
} from './onSpanEndUtils';
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin';
import { consumePendingDeepLink } 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 +232,7 @@ export const reactNavigationIntegration = ({
let latestNavigationSpan: Span | undefined;
let latestNavigationSpanNameCustomized: boolean = false;
let navigationProcessingSpan: Span | undefined;
let deepLinkAppliedToLatestSpan: boolean = false;

let initialStateHandled: boolean = false;
let isSetupComplete: boolean = false;
Expand Down Expand Up @@ -463,6 +466,15 @@ export const reactNavigationIntegration = ({
if (pendingExpoRouter && latestNavigationSpan) {
latestNavigationSpan.setAttribute('navigation.method', pendingExpoRouter.method);
}

// Try to attribute this span to a recently received deep link. Covers the
// warm-open case (link arrives โ†’ navigation dispatches). The cold-start
// case is handled again in `updateLatestNavigationSpanWithCurrentRoute`
// because `Linking.getInitialURL()` may resolve after this point.
deepLinkAppliedToLatestSpan = false;
if (latestNavigationSpan) {
deepLinkAppliedToLatestSpan = applyPendingDeepLinkToSpan(latestNavigationSpan, routeChangeTimeoutMs);
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (ignoreEmptyBackNavigationTransactions) {
ignoreEmptyBackNavigation(getClient(), latestNavigationSpan);
}
Expand Down Expand Up @@ -555,6 +567,13 @@ export const reactNavigationIntegration = ({
routeName = getPathFromState(navigationState) || route.name;
}

// Cold-start fallback: if the deep link arrived *after* the idle navigation
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
// span was started (e.g. `getInitialURL()` resolved post-`afterAllSetup`),
// try again now that the route has mounted.
if (!deepLinkAppliedToLatestSpan) {
deepLinkAppliedToLatestSpan = applyPendingDeepLinkToSpan(latestNavigationSpan, routeChangeTimeoutMs);
}

navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${routeName} mounted`);
navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK });
navigationProcessingSpan?.end(stateChangedTimestamp);
Expand Down Expand Up @@ -600,6 +619,7 @@ export const reactNavigationIntegration = ({
}
// Clear the latest transaction as it has been handled.
latestNavigationSpan = undefined;
deepLinkAppliedToLatestSpan = false;
};

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

const clearStateChangeTimeout = (): void => {
Expand Down Expand Up @@ -673,6 +694,30 @@ interface NavigationContainer {
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?

function applyPendingDeepLinkToSpan(span: Span, maxAgeMs: number): boolean {
const pending = consumePendingDeepLink(maxAgeMs);
if (!pending) {
return false;
}

const sendDefaultPii = getClient()?.getOptions()?.sendDefaultPii ?? false;
const url = sendDefaultPii ? pending.url : sanitizeDeepLinkUrl(pending.url);

span.setAttributes({
'navigation.trigger': 'deeplink',
'deeplink.url': url,
'deeplink.received_at': Math.max(0, Date.now() - pending.receivedAtMs),
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
});
return true;
}

/**
* Extracts the route name from a React Navigation dispatch action payload.
*
Expand Down
57 changes: 57 additions & 0 deletions packages/core/test/tracing/pendingDeepLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { clearPendingDeepLink, consumePendingDeepLink, setPendingDeepLink } 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');
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');
expect(consumePendingDeepLink(1_000)?.url).toBe('myapp://a');
expect(consumePendingDeepLink(1_000)).toBeUndefined();
});

it('overwrites a previous pending value', () => {
setPendingDeepLink('myapp://old');
setPendingDeepLink('myapp://new');
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');
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');
clearPendingDeepLink();
expect(consumePendingDeepLink(1_000)).toBeUndefined();
});
});
Loading
Loading