diff --git a/CHANGELOG.md b/CHANGELOG.md index d1d8e9c2e6..12b080bd44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Enable "Open Sentry" button in Playground for Expo apps ([#5947](https://github.com/getsentry/sentry-react-native/pull/5947)) - Add `attachAllThreads` option to attach full stack traces for all threads to captured events on iOS ([#5960](https://github.com/getsentry/sentry-react-native/issues/5960)) +- Add `deeplinkIntegration` for automatic deep link breadcrumbs ([#5983](https://github.com/getsentry/sentry-react-native/pull/5983)) - Name navigation spans using dispatched action payload when `useDispatchedActionData` is enabled ([#5982](https://github.com/getsentry/sentry-react-native/pull/5982)) ### Fixes diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts new file mode 100644 index 0000000000..e01f045fb1 --- /dev/null +++ b/packages/core/src/js/integrations/deeplink.ts @@ -0,0 +1,144 @@ +import type { IntegrationFn } from '@sentry/core'; + +import { addBreadcrumb, defineIntegration, getClient } from '@sentry/core'; + +import { sanitizeUrl } from '../tracing/utils'; + +export const INTEGRATION_NAME = 'DeepLink'; + +interface LinkingSubscription { + remove: () => void; +} + +interface RNLinking { + getInitialURL: () => Promise; + addEventListener: (event: string, handler: (event: { url: string }) => void) => LinkingSubscription; +} + +/** + * Replaces dynamic path segments (UUID-like or numeric values) with a placeholder + * to avoid capturing PII in path segments when `sendDefaultPii` is off. + * + * Only replaces segments that look like identifiers (all digits, UUIDs, or hex strings). + */ +function sanitizeDeepLinkUrl(url: string): string { + const stripped = sanitizeUrl(url); + + // Split off the scheme+authority (e.g. "myapp://host") so the regex + // only operates on the path and cannot corrupt the hostname. + const authorityEnd = stripped.indexOf('/', stripped.indexOf('//') + 2); + if (authorityEnd === -1) { + return stripped; + } + + const authority = stripped.slice(0, authorityEnd); + const path = stripped.slice(authorityEnd); + + // Replace path segments that look like dynamic IDs: + // - Numeric segments (e.g. /123) + // - UUID-formatted segments (e.g. /a1b2c3d4-e5f6-7890-abcd-ef1234567890) + // - Hex strings ≥8 chars (e.g. /deadbeef1234) + const sanitizedPath = path.replace( + /\/([0-9]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|[a-f0-9]{8,})(?=\/|$)/gi, + '/', + ); + + return authority + sanitizedPath; +} + +/** + * Returns the URL to include in the breadcrumb, respecting `sendDefaultPii`. + * When PII is disabled, query strings and ID-like path segments are removed. + */ +function getBreadcrumbUrl(url: string): string { + const sendDefaultPii = getClient()?.getOptions()?.sendDefaultPii ?? false; + return sendDefaultPii ? url : sanitizeDeepLinkUrl(url); +} + +function addDeepLinkBreadcrumb(url: string): void { + const breadcrumbUrl = getBreadcrumbUrl(url); + addBreadcrumb({ + category: 'deeplink', + type: 'navigation', + message: breadcrumbUrl, + data: { + url: breadcrumbUrl, + }, + }); +} + +const _deeplinkIntegration: IntegrationFn = () => { + let subscription: LinkingSubscription | undefined; + + return { + name: INTEGRATION_NAME, + setup(client) { + const linking = tryGetLinking(); + + if (!linking) { + return; + } + + // Remove previous subscription if setup is called again (e.g. repeated Sentry.init) + subscription?.remove(); + + // Cold start: app opened via deep link + linking + .getInitialURL() + .then((url: string | null) => { + if (url) { + addDeepLinkBreadcrumb(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); + } + }); + + client.on('close', () => { + subscription?.remove(); + subscription = undefined; + }); + }, + }; +}; + +/** + * Attempts to import React Native's Linking module without a hard dependency. + * Returns null if not available (e.g. in web environments). + */ +function tryGetLinking(): RNLinking | null { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return (require('react-native') as { Linking: RNLinking }).Linking ?? null; + } catch { + return null; + } +} + +/** + * Integration that automatically captures breadcrumbs when deep links are received. + * + * Intercepts links via React Native's `Linking` API: + * - `getInitialURL` for cold starts (app opened via deep link) + * - `addEventListener('url', ...)` for warm opens (link received while running) + * + * Respects `sendDefaultPii`: when disabled, query params and ID-like path segments + * are stripped from the URL before it is recorded. + * + * Compatible with both Expo Router and plain React Navigation deep linking. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [deeplinkIntegration()], + * }); + * ``` + */ +export const deeplinkIntegration = defineIntegration(_deeplinkIntegration); diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index d4e80f8ef6..4319f6843e 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -29,6 +29,7 @@ export { primitiveTagIntegration } from './primitiveTagIntegration'; export { logEnricherIntegration } from './logEnricherIntegration'; export { graphqlIntegration } from './graphql'; export { supabaseIntegration } from './supabase'; +export { deeplinkIntegration } from './deeplink'; export { browserApiErrorsIntegration, diff --git a/packages/core/test/integrations/deeplink.test.ts b/packages/core/test/integrations/deeplink.test.ts new file mode 100644 index 0000000000..9c9b00c90f --- /dev/null +++ b/packages/core/test/integrations/deeplink.test.ts @@ -0,0 +1,302 @@ +import { addBreadcrumb, getClient } from '@sentry/core'; + +import { deeplinkIntegration } from '../../src/js/integrations/deeplink'; + +const mockGetInitialURL = jest.fn, []>(); +const mockAddEventListener = jest.fn<{ remove: () => void }, [string, (event: { url: string }) => void]>(); + +jest.mock('react-native', () => ({ + Linking: { + getInitialURL: (...args: unknown[]) => mockGetInitialURL(...args), + addEventListener: (...args: Parameters) => mockAddEventListener(...args), + }, +})); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + addBreadcrumb: jest.fn(), + getClient: jest.fn(), + }; +}); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; +const mockGetClient = getClient as jest.Mock; + +describe('deeplinkIntegration', () => { + const mockClient = { on: jest.fn() } as unknown as Parameters< + NonNullable['setup']> + >[0]; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetInitialURL.mockResolvedValue(null); + mockAddEventListener.mockReturnValue({ remove: jest.fn() }); + mockGetClient.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: false }), + }); + }); + + describe('cold start (getInitialURL)', () => { + it('adds a breadcrumb when app opened via deep link', async () => { + mockGetInitialURL.mockResolvedValue('myapp://profile/123?token=secret'); + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + await Promise.resolve(); // flush microtasks + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'deeplink', + type: 'navigation', + }), + ); + }); + + it('strips query params and ID segments when sendDefaultPii is false', async () => { + mockGetInitialURL.mockResolvedValue('myapp://profile/123?token=secret'); + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + await Promise.resolve(); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'myapp://profile/', + data: { url: 'myapp://profile/' }, + }), + ); + }); + + it('keeps full URL when sendDefaultPii is true', async () => { + mockGetClient.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + }); + mockGetInitialURL.mockResolvedValue('myapp://profile/123?token=secret'); + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + await Promise.resolve(); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'myapp://profile/123?token=secret', + data: { url: 'myapp://profile/123?token=secret' }, + }), + ); + }); + + it('does not add a breadcrumb when getInitialURL returns null', async () => { + mockGetInitialURL.mockResolvedValue(null); + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + await Promise.resolve(); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('does not throw when getInitialURL rejects', async () => { + mockGetInitialURL.mockRejectedValue(new Error('Linking error')); + + const integration = deeplinkIntegration(); + expect(() => integration.setup?.(mockClient)).not.toThrow(); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + }); + + describe('warm open (url event)', () => { + it('adds a breadcrumb when a url event is received', () => { + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + const handler = mockAddEventListener.mock.calls[0]?.[1]; + handler?.({ url: 'myapp://notifications/456' }); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'deeplink', + type: 'navigation', + }), + ); + }); + + it('strips query params and ID segments on url event when sendDefaultPii is false', () => { + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + const handler = mockAddEventListener.mock.calls[0]?.[1]; + handler?.({ url: 'myapp://notifications/456?ref=push' }); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'myapp://notifications/', + data: { url: 'myapp://notifications/' }, + }), + ); + }); + + it('keeps full URL on url event when sendDefaultPii is true', () => { + mockGetClient.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + }); + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + const handler = mockAddEventListener.mock.calls[0]?.[1]; + handler?.({ url: 'myapp://notifications/456?ref=push' }); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'myapp://notifications/456?ref=push', + data: { url: 'myapp://notifications/456?ref=push' }, + }), + ); + }); + + it('registers the url event listener on setup', () => { + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + expect(mockAddEventListener).toHaveBeenCalledWith('url', expect.any(Function)); + }); + }); + + describe('subscription cleanup', () => { + it('removes the url event listener when the client closes', () => { + const mockRemove = jest.fn(); + mockAddEventListener.mockReturnValue({ remove: mockRemove }); + + const closeHandlers: (() => void)[] = []; + const mockClient = { + on: (event: string, handler: () => void) => { + if (event === 'close') { + closeHandlers.push(handler); + } + }, + }; + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient as Parameters>[0]); + + expect(mockRemove).not.toHaveBeenCalled(); + + closeHandlers.forEach(h => h()); + + expect(mockRemove).toHaveBeenCalledTimes(1); + }); + }); + + describe('repeated setup', () => { + it('removes previous subscription when setup is called again', () => { + const mockRemove1 = jest.fn(); + const mockRemove2 = jest.fn(); + mockAddEventListener.mockReturnValueOnce({ remove: mockRemove1 }).mockReturnValueOnce({ remove: mockRemove2 }); + + const closeHandlers: (() => void)[] = []; + const client = { + on: (event: string, handler: () => void) => { + if (event === 'close') { + closeHandlers.push(handler); + } + }, + } as unknown as Parameters['setup']>>[0]; + + const integration = deeplinkIntegration(); + + // First setup + integration.setup?.(client); + expect(mockRemove1).not.toHaveBeenCalled(); + + // Second setup — should remove previous subscription + integration.setup?.(client); + expect(mockRemove1).toHaveBeenCalledTimes(1); + expect(mockRemove2).not.toHaveBeenCalled(); + }); + }); + + describe('URL sanitization', () => { + it('does not alter non-ID path segments', async () => { + mockGetInitialURL.mockResolvedValue('myapp://settings/profile'); + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + await Promise.resolve(); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'myapp://settings/profile', + }), + ); + }); + + it('strips URL fragments when sendDefaultPii is false', async () => { + mockGetInitialURL.mockResolvedValue('myapp://page#user=john'); + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + await Promise.resolve(); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'myapp://page', + data: { url: 'myapp://page' }, + }), + ); + }); + + it('strips both query string and fragment when sendDefaultPii is false', async () => { + mockGetInitialURL.mockResolvedValue('myapp://page/123?token=secret#section'); + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + await Promise.resolve(); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'myapp://page/', + data: { url: 'myapp://page/' }, + }), + ); + }); + + it('does not replace hostname that resembles a hex string', async () => { + mockGetInitialURL.mockResolvedValue('myapp://deadbeef/profile'); + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + await Promise.resolve(); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'myapp://deadbeef/profile', + data: { url: 'myapp://deadbeef/profile' }, + }), + ); + }); + + it('replaces UUID-like segments', async () => { + mockGetInitialURL.mockResolvedValue('myapp://order/a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + + const integration = deeplinkIntegration(); + integration.setup?.(mockClient); + + await Promise.resolve(); + + const call = mockAddBreadcrumb.mock.calls[0]?.[0]; + expect(call?.message).not.toContain('a1b2c3d4'); + }); + }); +});