From f8614a8d623b89ab1eedabefcba37ea3e47566e2 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 13 Apr 2026 16:09:22 +0200 Subject: [PATCH 01/10] feat(core): add deeplinkIntegration for automatic deep link breadcrumbs Introduces a new `deeplinkIntegration` that automatically captures breadcrumbs whenever the app is opened or foregrounded via a deep link. - Intercepts cold-start links via `Linking.getInitialURL()` - Intercepts warm-open links via `Linking.addEventListener('url', ...)` - Breadcrumbs use `category: 'deeplink'` and `type: 'navigation'` - Respects `sendDefaultPii`: when false, query strings are stripped and numeric / UUID / long-hex path segments are replaced with `` - Compatible with both Expo Router and plain React Navigation deep linking (uses the standard RN `Linking` API, no framework-specific dependencies) - Gracefully skips setup when Linking is unavailable (e.g. web) Closes #5424 --- packages/core/src/js/integrations/deeplink.ts | 117 ++++++++++ packages/core/src/js/integrations/exports.ts | 1 + .../core/test/integrations/deeplink.test.ts | 199 ++++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 packages/core/src/js/integrations/deeplink.ts create mode 100644 packages/core/test/integrations/deeplink.test.ts diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts new file mode 100644 index 0000000000..5fcd6912a3 --- /dev/null +++ b/packages/core/src/js/integrations/deeplink.ts @@ -0,0 +1,117 @@ +import type { IntegrationFn } from '@sentry/core'; +import { addBreadcrumb, defineIntegration, getClient } from '@sentry/core'; + +export const INTEGRATION_NAME = 'DeepLink'; + +/** + * Strips the query string from a URL. + */ +function stripQueryString(url: string): string { + const queryIndex = url.indexOf('?'); + return queryIndex !== -1 ? url.slice(0, queryIndex) : url; +} + +/** + * 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 sanitizeUrl(url: string): string { + const withoutQuery = stripQueryString(url); + + // 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) + return withoutQuery.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, + '/', + ); +} + +/** + * 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 : sanitizeUrl(url); +} + +function addDeepLinkBreadcrumb(url: string): void { + addBreadcrumb({ + category: 'deeplink', + type: 'navigation', + message: getBreadcrumbUrl(url), + data: { + url: getBreadcrumbUrl(url), + }, + }); +} + +const _deeplinkIntegration: IntegrationFn = () => { + return { + name: INTEGRATION_NAME, + setup(_client) { + const Linking = tryGetLinking(); + + if (!Linking) { + return; + } + + // 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 + Linking.addEventListener('url', (event: { url: string }) => { + if (event?.url) { + addDeepLinkBreadcrumb(event.url); + } + }); + }, + }; +}; + +/** + * Attempts to import React Native's Linking module without a hard dependency. + * Returns null if not available (e.g. in web environments). + */ +function tryGetLinking(): { getInitialURL: () => Promise; addEventListener: (event: string, handler: (event: { url: string }) => void) => void } | null { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Linking } = require('react-native') as { Linking: { getInitialURL: () => Promise; addEventListener: (event: string, handler: (event: { url: string }) => void) => void } }; + return 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..95bfaf7903 --- /dev/null +++ b/packages/core/test/integrations/deeplink.test.ts @@ -0,0 +1,199 @@ +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', () => { + 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?.({} as Parameters>[0]); + + 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?.({} as Parameters>[0]); + + 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?.({} as Parameters>[0]); + + 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?.({} as Parameters>[0]); + + 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?.({} as Parameters>[0]), + ).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?.({} as Parameters>[0]); + + 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?.({} as Parameters>[0]); + + 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?.({} as Parameters>[0]); + + 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?.({} as Parameters>[0]); + + expect(mockAddEventListener).toHaveBeenCalledWith('url', expect.any(Function)); + }); + }); + + describe('URL sanitization', () => { + it('does not alter non-ID path segments', async () => { + mockGetInitialURL.mockResolvedValue('myapp://settings/profile'); + + const integration = deeplinkIntegration(); + integration.setup?.({} as Parameters>[0]); + + await Promise.resolve(); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'myapp://settings/profile', + }), + ); + }); + + it('replaces UUID-like segments', async () => { + mockGetInitialURL.mockResolvedValue('myapp://order/a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + + const integration = deeplinkIntegration(); + integration.setup?.({} as Parameters>[0]); + + await Promise.resolve(); + + const call = mockAddBreadcrumb.mock.calls[0]?.[0]; + expect(call?.message).not.toContain('a1b2c3d4'); + }); + }); +}); From 81ce886c98321147fc792574f4fa8b6b7dc6d188 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 14 Apr 2026 10:54:43 +0200 Subject: [PATCH 02/10] docs: add changelog entry for deeplinkIntegration --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39958f463d..2b34c03bd5 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)) ### Fixes From 2f72cfac4ca47a0f44e51bc42652f92bc098dd66 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 14 Apr 2026 11:16:41 +0200 Subject: [PATCH 03/10] fix(core): Address review feedback in deeplinkIntegration - Strip URL fragments (#) in addition to query strings when sendDefaultPii is off, preventing PII leaks via fragment identifiers - Store the Linking event listener subscription and remove it on client close to prevent resource leaks and duplicate breadcrumbs on hot reload - Cache getBreadcrumbUrl result to avoid redundant getClient lookups and regex sanitization - Extract RNLinking and LinkingSubscription interfaces for cleaner types - Add tests for fragment stripping, combined query+fragment stripping, and subscription cleanup on client close --- packages/core/src/js/integrations/deeplink.ts | 38 ++++++--- .../core/test/integrations/deeplink.test.ts | 81 ++++++++++++++++--- 2 files changed, 97 insertions(+), 22 deletions(-) diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts index 5fcd6912a3..6021ad92fd 100644 --- a/packages/core/src/js/integrations/deeplink.ts +++ b/packages/core/src/js/integrations/deeplink.ts @@ -4,11 +4,13 @@ import { addBreadcrumb, defineIntegration, getClient } from '@sentry/core'; export const INTEGRATION_NAME = 'DeepLink'; /** - * Strips the query string from a URL. + * Strips the query string and fragment from a URL. */ -function stripQueryString(url: string): string { - const queryIndex = url.indexOf('?'); - return queryIndex !== -1 ? url.slice(0, queryIndex) : url; +function stripQueryAndFragment(url: string): string { + const hashIndex = url.indexOf('#'); + const withoutFragment = hashIndex !== -1 ? url.slice(0, hashIndex) : url; + const queryIndex = withoutFragment.indexOf('?'); + return queryIndex !== -1 ? withoutFragment.slice(0, queryIndex) : withoutFragment; } /** @@ -18,7 +20,7 @@ function stripQueryString(url: string): string { * Only replaces segments that look like identifiers (all digits, UUIDs, or hex strings). */ function sanitizeUrl(url: string): string { - const withoutQuery = stripQueryString(url); + const withoutQuery = stripQueryAndFragment(url); // Replace path segments that look like dynamic IDs: // - Numeric segments (e.g. /123) @@ -40,12 +42,13 @@ function getBreadcrumbUrl(url: string): string { } function addDeepLinkBreadcrumb(url: string): void { + const breadcrumbUrl = getBreadcrumbUrl(url); addBreadcrumb({ category: 'deeplink', type: 'navigation', - message: getBreadcrumbUrl(url), + message: breadcrumbUrl, data: { - url: getBreadcrumbUrl(url), + url: breadcrumbUrl, }, }); } @@ -53,7 +56,7 @@ function addDeepLinkBreadcrumb(url: string): void { const _deeplinkIntegration: IntegrationFn = () => { return { name: INTEGRATION_NAME, - setup(_client) { + setup(client) { const Linking = tryGetLinking(); if (!Linking) { @@ -72,11 +75,15 @@ const _deeplinkIntegration: IntegrationFn = () => { }); // Warm open: deep link received while app is running - Linking.addEventListener('url', (event: { url: string }) => { + const subscription = Linking.addEventListener('url', (event: { url: string }) => { if (event?.url) { addDeepLinkBreadcrumb(event.url); } }); + + client.on('close', () => { + subscription?.remove(); + }); }, }; }; @@ -85,10 +92,19 @@ const _deeplinkIntegration: IntegrationFn = () => { * Attempts to import React Native's Linking module without a hard dependency. * Returns null if not available (e.g. in web environments). */ -function tryGetLinking(): { getInitialURL: () => Promise; addEventListener: (event: string, handler: (event: { url: string }) => void) => void } | null { +interface LinkingSubscription { + remove: () => void; +} + +interface RNLinking { + getInitialURL: () => Promise; + addEventListener: (event: string, handler: (event: { url: string }) => void) => LinkingSubscription; +} + +function tryGetLinking(): RNLinking | null { try { // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Linking } = require('react-native') as { Linking: { getInitialURL: () => Promise; addEventListener: (event: string, handler: (event: { url: string }) => void) => void } }; + const { Linking } = require('react-native') as { Linking: RNLinking }; return Linking ?? null; } catch { return null; diff --git a/packages/core/test/integrations/deeplink.test.ts b/packages/core/test/integrations/deeplink.test.ts index 95bfaf7903..9c641fc533 100644 --- a/packages/core/test/integrations/deeplink.test.ts +++ b/packages/core/test/integrations/deeplink.test.ts @@ -25,6 +25,8 @@ const mockAddBreadcrumb = addBreadcrumb as jest.Mock; const mockGetClient = getClient as jest.Mock; describe('deeplinkIntegration', () => { + const mockClient = { on: jest.fn() } as unknown as Parameters['setup']>>[0]; + beforeEach(() => { jest.clearAllMocks(); mockGetInitialURL.mockResolvedValue(null); @@ -39,7 +41,7 @@ describe('deeplinkIntegration', () => { mockGetInitialURL.mockResolvedValue('myapp://profile/123?token=secret'); const integration = deeplinkIntegration(); - integration.setup?.({} as Parameters>[0]); + integration.setup?.(mockClient); await Promise.resolve(); // flush microtasks @@ -55,7 +57,7 @@ describe('deeplinkIntegration', () => { mockGetInitialURL.mockResolvedValue('myapp://profile/123?token=secret'); const integration = deeplinkIntegration(); - integration.setup?.({} as Parameters>[0]); + integration.setup?.(mockClient); await Promise.resolve(); @@ -74,7 +76,7 @@ describe('deeplinkIntegration', () => { mockGetInitialURL.mockResolvedValue('myapp://profile/123?token=secret'); const integration = deeplinkIntegration(); - integration.setup?.({} as Parameters>[0]); + integration.setup?.(mockClient); await Promise.resolve(); @@ -90,7 +92,7 @@ describe('deeplinkIntegration', () => { mockGetInitialURL.mockResolvedValue(null); const integration = deeplinkIntegration(); - integration.setup?.({} as Parameters>[0]); + integration.setup?.(mockClient); await Promise.resolve(); @@ -102,7 +104,7 @@ describe('deeplinkIntegration', () => { const integration = deeplinkIntegration(); expect(() => - integration.setup?.({} as Parameters>[0]), + integration.setup?.(mockClient), ).not.toThrow(); await new Promise(resolve => setTimeout(resolve, 0)); @@ -113,7 +115,7 @@ describe('deeplinkIntegration', () => { describe('warm open (url event)', () => { it('adds a breadcrumb when a url event is received', () => { const integration = deeplinkIntegration(); - integration.setup?.({} as Parameters>[0]); + integration.setup?.(mockClient); const handler = mockAddEventListener.mock.calls[0]?.[1]; handler?.({ url: 'myapp://notifications/456' }); @@ -128,7 +130,7 @@ describe('deeplinkIntegration', () => { it('strips query params and ID segments on url event when sendDefaultPii is false', () => { const integration = deeplinkIntegration(); - integration.setup?.({} as Parameters>[0]); + integration.setup?.(mockClient); const handler = mockAddEventListener.mock.calls[0]?.[1]; handler?.({ url: 'myapp://notifications/456?ref=push' }); @@ -147,7 +149,7 @@ describe('deeplinkIntegration', () => { }); const integration = deeplinkIntegration(); - integration.setup?.({} as Parameters>[0]); + integration.setup?.(mockClient); const handler = mockAddEventListener.mock.calls[0]?.[1]; handler?.({ url: 'myapp://notifications/456?ref=push' }); @@ -162,18 +164,43 @@ describe('deeplinkIntegration', () => { it('registers the url event listener on setup', () => { const integration = deeplinkIntegration(); - integration.setup?.({} as Parameters>[0]); + 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('URL sanitization', () => { it('does not alter non-ID path segments', async () => { mockGetInitialURL.mockResolvedValue('myapp://settings/profile'); const integration = deeplinkIntegration(); - integration.setup?.({} as Parameters>[0]); + integration.setup?.(mockClient); await Promise.resolve(); @@ -184,11 +211,43 @@ describe('deeplinkIntegration', () => { ); }); + 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('replaces UUID-like segments', async () => { mockGetInitialURL.mockResolvedValue('myapp://order/a1b2c3d4-e5f6-7890-abcd-ef1234567890'); const integration = deeplinkIntegration(); - integration.setup?.({} as Parameters>[0]); + integration.setup?.(mockClient); await Promise.resolve(); From 1a197350a598572bdb30f8677e0e5e16059236ef Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 14 Apr 2026 11:36:43 +0200 Subject: [PATCH 04/10] refactor(core): Reuse existing sanitizeUrl from tracing/utils in deeplinkIntegration Replace the duplicated stripQueryAndFragment/sanitizeUrl logic with the existing exported sanitizeUrl from tracing/utils.ts, which already strips query strings and fragments. The deeplink-specific ID replacement regex is kept in a new sanitizeDeepLinkUrl wrapper. --- packages/core/src/js/integrations/deeplink.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts index 6021ad92fd..90ea4f4fa4 100644 --- a/packages/core/src/js/integrations/deeplink.ts +++ b/packages/core/src/js/integrations/deeplink.ts @@ -1,17 +1,9 @@ import type { IntegrationFn } from '@sentry/core'; import { addBreadcrumb, defineIntegration, getClient } from '@sentry/core'; -export const INTEGRATION_NAME = 'DeepLink'; +import { sanitizeUrl } from '../tracing/utils'; -/** - * Strips the query string and fragment from a URL. - */ -function stripQueryAndFragment(url: string): string { - const hashIndex = url.indexOf('#'); - const withoutFragment = hashIndex !== -1 ? url.slice(0, hashIndex) : url; - const queryIndex = withoutFragment.indexOf('?'); - return queryIndex !== -1 ? withoutFragment.slice(0, queryIndex) : withoutFragment; -} +export const INTEGRATION_NAME = 'DeepLink'; /** * Replaces dynamic path segments (UUID-like or numeric values) with a placeholder @@ -19,14 +11,14 @@ function stripQueryAndFragment(url: string): string { * * Only replaces segments that look like identifiers (all digits, UUIDs, or hex strings). */ -function sanitizeUrl(url: string): string { - const withoutQuery = stripQueryAndFragment(url); +function sanitizeDeepLinkUrl(url: string): string { + const stripped = sanitizeUrl(url); // 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) - return withoutQuery.replace( + return stripped.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, '/', ); @@ -38,7 +30,7 @@ function sanitizeUrl(url: string): string { */ function getBreadcrumbUrl(url: string): string { const sendDefaultPii = getClient()?.getOptions()?.sendDefaultPii ?? false; - return sendDefaultPii ? url : sanitizeUrl(url); + return sendDefaultPii ? url : sanitizeDeepLinkUrl(url); } function addDeepLinkBreadcrumb(url: string): void { From 4b134bc2f7c4088945eacf39821b39c1e69b553b Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 14 Apr 2026 12:22:07 +0200 Subject: [PATCH 05/10] fix(core): Fix hostname corruption and repeated setup leak in deeplinkIntegration - Split URL into authority and path before applying ID-replacement regex so hostnames that resemble hex strings (e.g. myapp://deadbeef/...) are not incorrectly replaced with - Move subscription to the factory closure and remove it on repeated setup calls to prevent duplicate listeners when Sentry.init() is called more than once - Add tests for hostname preservation and repeated setup cleanup --- packages/core/src/js/integrations/deeplink.ts | 22 +++++++++- .../core/test/integrations/deeplink.test.ts | 44 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts index 90ea4f4fa4..21fe8704e4 100644 --- a/packages/core/src/js/integrations/deeplink.ts +++ b/packages/core/src/js/integrations/deeplink.ts @@ -14,14 +14,26 @@ export const INTEGRATION_NAME = 'DeepLink'; 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) - return stripped.replace( + 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; } /** @@ -46,6 +58,8 @@ function addDeepLinkBreadcrumb(url: string): void { } const _deeplinkIntegration: IntegrationFn = () => { + let subscription: LinkingSubscription | undefined; + return { name: INTEGRATION_NAME, setup(client) { @@ -55,6 +69,9 @@ const _deeplinkIntegration: IntegrationFn = () => { 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) => { @@ -67,7 +84,7 @@ const _deeplinkIntegration: IntegrationFn = () => { }); // Warm open: deep link received while app is running - const subscription = Linking.addEventListener('url', (event: { url: string }) => { + subscription = Linking.addEventListener('url', (event: { url: string }) => { if (event?.url) { addDeepLinkBreadcrumb(event.url); } @@ -75,6 +92,7 @@ const _deeplinkIntegration: IntegrationFn = () => { client.on('close', () => { subscription?.remove(); + subscription = undefined; }); }, }; diff --git a/packages/core/test/integrations/deeplink.test.ts b/packages/core/test/integrations/deeplink.test.ts index 9c641fc533..772d389978 100644 --- a/packages/core/test/integrations/deeplink.test.ts +++ b/packages/core/test/integrations/deeplink.test.ts @@ -195,6 +195,34 @@ describe('deeplinkIntegration', () => { }); }); + 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'); @@ -243,6 +271,22 @@ describe('deeplinkIntegration', () => { ); }); + 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'); From 15924aa8e85853965363eba804642b174ba56224 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 14 Apr 2026 12:28:25 +0200 Subject: [PATCH 06/10] style: Fix lint issues in deeplinkIntegration --- packages/core/src/js/integrations/deeplink.ts | 1 + packages/core/test/integrations/deeplink.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts index 21fe8704e4..1fe2cf809f 100644 --- a/packages/core/src/js/integrations/deeplink.ts +++ b/packages/core/src/js/integrations/deeplink.ts @@ -1,4 +1,5 @@ import type { IntegrationFn } from '@sentry/core'; + import { addBreadcrumb, defineIntegration, getClient } from '@sentry/core'; import { sanitizeUrl } from '../tracing/utils'; diff --git a/packages/core/test/integrations/deeplink.test.ts b/packages/core/test/integrations/deeplink.test.ts index 772d389978..9c9b00c90f 100644 --- a/packages/core/test/integrations/deeplink.test.ts +++ b/packages/core/test/integrations/deeplink.test.ts @@ -25,7 +25,9 @@ const mockAddBreadcrumb = addBreadcrumb as jest.Mock; const mockGetClient = getClient as jest.Mock; describe('deeplinkIntegration', () => { - const mockClient = { on: jest.fn() } as unknown as Parameters['setup']>>[0]; + const mockClient = { on: jest.fn() } as unknown as Parameters< + NonNullable['setup']> + >[0]; beforeEach(() => { jest.clearAllMocks(); @@ -103,9 +105,7 @@ describe('deeplinkIntegration', () => { mockGetInitialURL.mockRejectedValue(new Error('Linking error')); const integration = deeplinkIntegration(); - expect(() => - integration.setup?.(mockClient), - ).not.toThrow(); + expect(() => integration.setup?.(mockClient)).not.toThrow(); await new Promise(resolve => setTimeout(resolve, 0)); expect(mockAddBreadcrumb).not.toHaveBeenCalled(); From 2ccb63ecbaf2e4775dc8d2057d5b53acc6dcad88 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 14 Apr 2026 12:35:47 +0200 Subject: [PATCH 07/10] style: Move interfaces to top and attach JSDoc to tryGetLinking --- packages/core/src/js/integrations/deeplink.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts index 1fe2cf809f..1a69c751fb 100644 --- a/packages/core/src/js/integrations/deeplink.ts +++ b/packages/core/src/js/integrations/deeplink.ts @@ -6,6 +6,15 @@ 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. @@ -103,15 +112,6 @@ const _deeplinkIntegration: IntegrationFn = () => { * Attempts to import React Native's Linking module without a hard dependency. * Returns null if not available (e.g. in web environments). */ -interface LinkingSubscription { - remove: () => void; -} - -interface RNLinking { - getInitialURL: () => Promise; - addEventListener: (event: string, handler: (event: { url: string }) => void) => LinkingSubscription; -} - function tryGetLinking(): RNLinking | null { try { // eslint-disable-next-line @typescript-eslint/no-var-requires From 5c4aa9971b99ec54f37821d3e1ea634c77e84929 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 14 Apr 2026 12:45:15 +0200 Subject: [PATCH 08/10] fix(core): Rename Linking variable to avoid duplicate declaration in compiled output The local variable 'Linking' from tryGetLinking() collided with the destructured 'Linking' from require('react-native') in the compiled JS, causing a 'Duplicate declaration' build error. Renamed to 'linking'. --- packages/core/android/libs/replay-stubs.jar | Bin 1198 -> 1198 bytes packages/core/src/js/integrations/deeplink.ts | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/android/libs/replay-stubs.jar b/packages/core/android/libs/replay-stubs.jar index 0bc261679e59f5c6e4dfd3b8aae95abef8c960a7..f1c745dd58b2660d9186131d319a1199caccbddb 100644 GIT binary patch delta 153 zcmZ3-xsH=Jz?+#xgn@yBgJDT(-$Y(DmZ?eZ(G$%*)j>1^5HNuVAYcaK%zS-j280f< z(&bPs7xkE|@+UJgngglLR*X{^nSlmQzQ|+(HgPg1vmJ!t&8!Py)G$M(wlLd(w0>fC J1yPnPr2veFCvX4& delta 153 zcmZ3-xsH=Jz?+#xgn@yBgW+dD??hfT79bTf(cDuVL^A*Z6Nmr;W+2YY*Joxx(GfFo zIaJFKQX(4 IC`*=70BiU!HUIzs diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts index 1a69c751fb..0012b9771a 100644 --- a/packages/core/src/js/integrations/deeplink.ts +++ b/packages/core/src/js/integrations/deeplink.ts @@ -73,9 +73,9 @@ const _deeplinkIntegration: IntegrationFn = () => { return { name: INTEGRATION_NAME, setup(client) { - const Linking = tryGetLinking(); + const linking = tryGetLinking(); - if (!Linking) { + if (!linking) { return; } @@ -83,7 +83,7 @@ const _deeplinkIntegration: IntegrationFn = () => { subscription?.remove(); // Cold start: app opened via deep link - Linking.getInitialURL() + linking.getInitialURL() .then((url: string | null) => { if (url) { addDeepLinkBreadcrumb(url); @@ -94,7 +94,7 @@ const _deeplinkIntegration: IntegrationFn = () => { }); // Warm open: deep link received while app is running - subscription = Linking.addEventListener('url', (event: { url: string }) => { + subscription = linking.addEventListener('url', (event: { url: string }) => { if (event?.url) { addDeepLinkBreadcrumb(event.url); } From 5f4ed900b22963dfc65557529639e98b94d60c38 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 14 Apr 2026 15:33:23 +0200 Subject: [PATCH 09/10] fix(core): Fix Linking duplicate declaration and revert unrelated replay-stubs.jar - Rename destructured Linking to rnLinking inside tryGetLinking to avoid Babel/Metro duplicate declaration errors in compiled output - Fix getInitialURL call chain formatting per lint - Revert unrelated replay-stubs.jar binary change --- packages/core/android/libs/replay-stubs.jar | Bin 1198 -> 1198 bytes packages/core/src/js/integrations/deeplink.ts | 7 ++++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/android/libs/replay-stubs.jar b/packages/core/android/libs/replay-stubs.jar index f1c745dd58b2660d9186131d319a1199caccbddb..0bc261679e59f5c6e4dfd3b8aae95abef8c960a7 100644 GIT binary patch delta 153 zcmZ3-xsH=Jz?+#xgn@yBgW+dD??hfT79bTf(cDuVL^A*Z6Nmr;W+2YY*Joxx(GfFo zIaJFKQX(4 IC`*=70BiU!HUIzs delta 153 zcmZ3-xsH=Jz?+#xgn@yBgJDT(-$Y(DmZ?eZ(G$%*)j>1^5HNuVAYcaK%zS-j280f< z(&bPs7xkE|@+UJgngglLR*X{^nSlmQzQ|+(HgPg1vmJ!t&8!Py)G$M(wlLd(w0>fC J1yPnPr2veFCvX4& diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts index 0012b9771a..4989b5fc6f 100644 --- a/packages/core/src/js/integrations/deeplink.ts +++ b/packages/core/src/js/integrations/deeplink.ts @@ -83,7 +83,8 @@ const _deeplinkIntegration: IntegrationFn = () => { subscription?.remove(); // Cold start: app opened via deep link - linking.getInitialURL() + linking + .getInitialURL() .then((url: string | null) => { if (url) { addDeepLinkBreadcrumb(url); @@ -115,8 +116,8 @@ const _deeplinkIntegration: IntegrationFn = () => { function tryGetLinking(): RNLinking | null { try { // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Linking } = require('react-native') as { Linking: RNLinking }; - return Linking ?? null; + const { Linking: rnLinking } = require('react-native') as { Linking: RNLinking }; + return rnLinking ?? null; } catch { return null; } From 6e295a9bafcef9f8b5622ebc89168b4e01325680 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 14 Apr 2026 16:05:41 +0200 Subject: [PATCH 10/10] fix(core): Use direct property access for Linking to avoid Babel duplicate declaration Replace destructured import with direct property access on require result as suggested by @antonis to fix the persisting Babel/Metro build error. --- packages/core/src/js/integrations/deeplink.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/js/integrations/deeplink.ts b/packages/core/src/js/integrations/deeplink.ts index 4989b5fc6f..e01f045fb1 100644 --- a/packages/core/src/js/integrations/deeplink.ts +++ b/packages/core/src/js/integrations/deeplink.ts @@ -116,8 +116,7 @@ const _deeplinkIntegration: IntegrationFn = () => { function tryGetLinking(): RNLinking | null { try { // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Linking: rnLinking } = require('react-native') as { Linking: RNLinking }; - return rnLinking ?? null; + return (require('react-native') as { Linking: RNLinking }).Linking ?? null; } catch { return null; }