豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 7ac3378

Browse files
authored
Add deeplinkIntegration for automatic deep link breadcrumbs (#5983)
* 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 * docs: add changelog entry for deeplinkIntegration * 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 * 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. * 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 <id> - 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 * style: Fix lint issues in deeplinkIntegration * style: Move interfaces to top and attach JSDoc to tryGetLinking * 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'. * 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 * 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.
1 parent 04207c4 commit 7ac3378

File tree

4 files changed

+448
-0
lines changed

4 files changed

+448
-0
lines changed

CHANGELOG.md

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

1313
- Enable "Open Sentry" button in Playground for Expo apps ([#5947](https://github.com/getsentry/sentry-react-native/pull/5947))
1414
- 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))
15+
- Add `deeplinkIntegration` for automatic deep link breadcrumbs ([#5983](https://github.com/getsentry/sentry-react-native/pull/5983))
1516
- Name navigation spans using dispatched action payload when `useDispatchedActionData` is enabled ([#5982](https://github.com/getsentry/sentry-react-native/pull/5982))
1617

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

0 commit comments

Comments
 (0)