Skip to content

Commit f8614a8

Browse files
committed
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 `<id>` - 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
1 parent 3ce5254 commit f8614a8

3 files changed

Lines changed: 317 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { IntegrationFn } from '@sentry/core';
2+
import { addBreadcrumb, defineIntegration, getClient } from '@sentry/core';
3+
4+
export const INTEGRATION_NAME = 'DeepLink';
5+
6+
/**
7+
* Strips the query string from a URL.
8+
*/
9+
function stripQueryString(url: string): string {
10+
const queryIndex = url.indexOf('?');
11+
return queryIndex !== -1 ? url.slice(0, queryIndex) : url;
12+
}
13+
14+
/**
15+
* Replaces dynamic path segments (UUID-like or numeric values) with a placeholder
16+
* to avoid capturing PII in path segments when `sendDefaultPii` is off.
17+
*
18+
* Only replaces segments that look like identifiers (all digits, UUIDs, or hex strings).
19+
*/
20+
function sanitizeUrl(url: string): string {
21+
const withoutQuery = stripQueryString(url);
22+
23+
// Replace path segments that look like dynamic IDs:
24+
// - Numeric segments (e.g. /123)
25+
// - UUID-formatted segments (e.g. /a1b2c3d4-e5f6-7890-abcd-ef1234567890)
26+
// - Hex strings ≥8 chars (e.g. /deadbeef1234)
27+
return withoutQuery.replace(
28+
/\/([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,
29+
'/<id>',
30+
);
31+
}
32+
33+
/**
34+
* Returns the URL to include in the breadcrumb, respecting `sendDefaultPii`.
35+
* When PII is disabled, query strings and ID-like path segments are removed.
36+
*/
37+
function getBreadcrumbUrl(url: string): string {
38+
const sendDefaultPii = getClient()?.getOptions()?.sendDefaultPii ?? false;
39+
return sendDefaultPii ? url : sanitizeUrl(url);
40+
}
41+
42+
function addDeepLinkBreadcrumb(url: string): void {
43+
addBreadcrumb({
44+
category: 'deeplink',
45+
type: 'navigation',
46+
message: getBreadcrumbUrl(url),
47+
data: {
48+
url: getBreadcrumbUrl(url),
49+
},
50+
});
51+
}
52+
53+
const _deeplinkIntegration: IntegrationFn = () => {
54+
return {
55+
name: INTEGRATION_NAME,
56+
setup(_client) {
57+
const Linking = tryGetLinking();
58+
59+
if (!Linking) {
60+
return;
61+
}
62+
63+
// Cold start: app opened via deep link
64+
Linking.getInitialURL()
65+
.then((url: string | null) => {
66+
if (url) {
67+
addDeepLinkBreadcrumb(url);
68+
}
69+
})
70+
.catch(() => {
71+
// Ignore errors from getInitialURL
72+
});
73+
74+
// Warm open: deep link received while app is running
75+
Linking.addEventListener('url', (event: { url: string }) => {
76+
if (event?.url) {
77+
addDeepLinkBreadcrumb(event.url);
78+
}
79+
});
80+
},
81+
};
82+
};
83+
84+
/**
85+
* Attempts to import React Native's Linking module without a hard dependency.
86+
* Returns null if not available (e.g. in web environments).
87+
*/
88+
function tryGetLinking(): { getInitialURL: () => Promise<string | null>; addEventListener: (event: string, handler: (event: { url: string }) => void) => void } | null {
89+
try {
90+
// eslint-disable-next-line @typescript-eslint/no-var-requires
91+
const { Linking } = require('react-native') as { Linking: { getInitialURL: () => Promise<string | null>; addEventListener: (event: string, handler: (event: { url: string }) => void) => void } };
92+
return Linking ?? null;
93+
} catch {
94+
return null;
95+
}
96+
}
97+
98+
/**
99+
* Integration that automatically captures breadcrumbs when deep links are received.
100+
*
101+
* Intercepts links via React Native's `Linking` API:
102+
* - `getInitialURL` for cold starts (app opened via deep link)
103+
* - `addEventListener('url', ...)` for warm opens (link received while running)
104+
*
105+
* Respects `sendDefaultPii`: when disabled, query params and ID-like path segments
106+
* are stripped from the URL before it is recorded.
107+
*
108+
* Compatible with both Expo Router and plain React Navigation deep linking.
109+
*
110+
* @example
111+
* ```ts
112+
* Sentry.init({
113+
* integrations: [deeplinkIntegration()],
114+
* });
115+
* ```
116+
*/
117+
export const deeplinkIntegration = defineIntegration(_deeplinkIntegration);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export { primitiveTagIntegration } from './primitiveTagIntegration';
2929
export { logEnricherIntegration } from './logEnricherIntegration';
3030
export { graphqlIntegration } from './graphql';
3131
export { supabaseIntegration } from './supabase';
32+
export { deeplinkIntegration } from './deeplink';
3233

3334
export {
3435
browserApiErrorsIntegration,
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { addBreadcrumb, getClient } from '@sentry/core';
2+
3+
import { deeplinkIntegration } from '../../src/js/integrations/deeplink';
4+
5+
const mockGetInitialURL = jest.fn<Promise<string | null>, []>();
6+
const mockAddEventListener = jest.fn<{ remove: () => void }, [string, (event: { url: string }) => void]>();
7+
8+
jest.mock('react-native', () => ({
9+
Linking: {
10+
getInitialURL: (...args: unknown[]) => mockGetInitialURL(...args),
11+
addEventListener: (...args: Parameters<typeof mockAddEventListener>) => mockAddEventListener(...args),
12+
},
13+
}));
14+
15+
jest.mock('@sentry/core', () => {
16+
const actual = jest.requireActual('@sentry/core');
17+
return {
18+
...actual,
19+
addBreadcrumb: jest.fn(),
20+
getClient: jest.fn(),
21+
};
22+
});
23+
24+
const mockAddBreadcrumb = addBreadcrumb as jest.Mock;
25+
const mockGetClient = getClient as jest.Mock;
26+
27+
describe('deeplinkIntegration', () => {
28+
beforeEach(() => {
29+
jest.clearAllMocks();
30+
mockGetInitialURL.mockResolvedValue(null);
31+
mockAddEventListener.mockReturnValue({ remove: jest.fn() });
32+
mockGetClient.mockReturnValue({
33+
getOptions: () => ({ sendDefaultPii: false }),
34+
});
35+
});
36+
37+
describe('cold start (getInitialURL)', () => {
38+
it('adds a breadcrumb when app opened via deep link', async () => {
39+
mockGetInitialURL.mockResolvedValue('myapp://profile/123?token=secret');
40+
41+
const integration = deeplinkIntegration();
42+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]);
43+
44+
await Promise.resolve(); // flush microtasks
45+
46+
expect(mockAddBreadcrumb).toHaveBeenCalledWith(
47+
expect.objectContaining({
48+
category: 'deeplink',
49+
type: 'navigation',
50+
}),
51+
);
52+
});
53+
54+
it('strips query params and ID segments when sendDefaultPii is false', async () => {
55+
mockGetInitialURL.mockResolvedValue('myapp://profile/123?token=secret');
56+
57+
const integration = deeplinkIntegration();
58+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]);
59+
60+
await Promise.resolve();
61+
62+
expect(mockAddBreadcrumb).toHaveBeenCalledWith(
63+
expect.objectContaining({
64+
message: 'myapp://profile/<id>',
65+
data: { url: 'myapp://profile/<id>' },
66+
}),
67+
);
68+
});
69+
70+
it('keeps full URL when sendDefaultPii is true', async () => {
71+
mockGetClient.mockReturnValue({
72+
getOptions: () => ({ sendDefaultPii: true }),
73+
});
74+
mockGetInitialURL.mockResolvedValue('myapp://profile/123?token=secret');
75+
76+
const integration = deeplinkIntegration();
77+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]);
78+
79+
await Promise.resolve();
80+
81+
expect(mockAddBreadcrumb).toHaveBeenCalledWith(
82+
expect.objectContaining({
83+
message: 'myapp://profile/123?token=secret',
84+
data: { url: 'myapp://profile/123?token=secret' },
85+
}),
86+
);
87+
});
88+
89+
it('does not add a breadcrumb when getInitialURL returns null', async () => {
90+
mockGetInitialURL.mockResolvedValue(null);
91+
92+
const integration = deeplinkIntegration();
93+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]);
94+
95+
await Promise.resolve();
96+
97+
expect(mockAddBreadcrumb).not.toHaveBeenCalled();
98+
});
99+
100+
it('does not throw when getInitialURL rejects', async () => {
101+
mockGetInitialURL.mockRejectedValue(new Error('Linking error'));
102+
103+
const integration = deeplinkIntegration();
104+
expect(() =>
105+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]),
106+
).not.toThrow();
107+
108+
await new Promise(resolve => setTimeout(resolve, 0));
109+
expect(mockAddBreadcrumb).not.toHaveBeenCalled();
110+
});
111+
});
112+
113+
describe('warm open (url event)', () => {
114+
it('adds a breadcrumb when a url event is received', () => {
115+
const integration = deeplinkIntegration();
116+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]);
117+
118+
const handler = mockAddEventListener.mock.calls[0]?.[1];
119+
handler?.({ url: 'myapp://notifications/456' });
120+
121+
expect(mockAddBreadcrumb).toHaveBeenCalledWith(
122+
expect.objectContaining({
123+
category: 'deeplink',
124+
type: 'navigation',
125+
}),
126+
);
127+
});
128+
129+
it('strips query params and ID segments on url event when sendDefaultPii is false', () => {
130+
const integration = deeplinkIntegration();
131+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]);
132+
133+
const handler = mockAddEventListener.mock.calls[0]?.[1];
134+
handler?.({ url: 'myapp://notifications/456?ref=push' });
135+
136+
expect(mockAddBreadcrumb).toHaveBeenCalledWith(
137+
expect.objectContaining({
138+
message: 'myapp://notifications/<id>',
139+
data: { url: 'myapp://notifications/<id>' },
140+
}),
141+
);
142+
});
143+
144+
it('keeps full URL on url event when sendDefaultPii is true', () => {
145+
mockGetClient.mockReturnValue({
146+
getOptions: () => ({ sendDefaultPii: true }),
147+
});
148+
149+
const integration = deeplinkIntegration();
150+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]);
151+
152+
const handler = mockAddEventListener.mock.calls[0]?.[1];
153+
handler?.({ url: 'myapp://notifications/456?ref=push' });
154+
155+
expect(mockAddBreadcrumb).toHaveBeenCalledWith(
156+
expect.objectContaining({
157+
message: 'myapp://notifications/456?ref=push',
158+
data: { url: 'myapp://notifications/456?ref=push' },
159+
}),
160+
);
161+
});
162+
163+
it('registers the url event listener on setup', () => {
164+
const integration = deeplinkIntegration();
165+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]);
166+
167+
expect(mockAddEventListener).toHaveBeenCalledWith('url', expect.any(Function));
168+
});
169+
});
170+
171+
describe('URL sanitization', () => {
172+
it('does not alter non-ID path segments', async () => {
173+
mockGetInitialURL.mockResolvedValue('myapp://settings/profile');
174+
175+
const integration = deeplinkIntegration();
176+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]);
177+
178+
await Promise.resolve();
179+
180+
expect(mockAddBreadcrumb).toHaveBeenCalledWith(
181+
expect.objectContaining({
182+
message: 'myapp://settings/profile',
183+
}),
184+
);
185+
});
186+
187+
it('replaces UUID-like segments', async () => {
188+
mockGetInitialURL.mockResolvedValue('myapp://order/a1b2c3d4-e5f6-7890-abcd-ef1234567890');
189+
190+
const integration = deeplinkIntegration();
191+
integration.setup?.({} as Parameters<NonNullable<typeof integration.setup>>[0]);
192+
193+
await Promise.resolve();
194+
195+
const call = mockAddBreadcrumb.mock.calls[0]?.[0];
196+
expect(call?.message).not.toContain('a1b2c3d4');
197+
});
198+
});
199+
});

0 commit comments

Comments
 (0)