Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -13,6 +13,7 @@
- Expose scope-level attributes API (`setAttribute`, `setAttributes`, `removeAttribute`) bridging to native SDKs ([#6009](https://github.com/getsentry/sentry-react-native/pull/6009))
- Expose screenshot masking options (`screenshot.maskAllText`, `screenshot.maskAllImages`, `screenshot.maskedViewClasses`, `screenshot.unmaskedViewClasses`) for error screenshots ([#6007](https://github.com/getsentry/sentry-react-native/pull/6007))
- Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984))
- Add `includeFeedback` Metro config option to exclude `@sentry-internal/feedback` from the bundle ([#6025](https://github.com/getsentry/sentry-react-native/pull/6025))
- Add `Sentry.GlobalErrorBoundary` component (and `withGlobalErrorBoundary` HOC) that renders a fallback UI for fatal non-rendering JS errors routed through `ErrorUtils` in addition to the render-phase errors caught by `Sentry.ErrorBoundary`. Opt-in flags `includeNonFatalGlobalErrors` and `includeUnhandledRejections` extend the fallback to non-fatal errors and unhandled promise rejections respectively. ([#6023](https://github.com/getsentry/sentry-react-native/pull/6023))

### Fixes
Expand Down
62 changes: 52 additions & 10 deletions packages/core/src/js/tools/metroconfig.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* oxlint-disable eslint(max-lines) */
import type { MetroConfig, MixedOutput, Module, ReadOnlyGraph } from 'metro';
import type { CustomResolutionContext, CustomResolver, Resolution } from 'metro-resolver';

Expand Down Expand Up @@ -38,6 +39,11 @@ export interface SentryMetroConfigOptions {
* @default true
*/
includeWebReplay?: boolean;
/**
* Adds the Sentry user feedback widget package for web.
* @default true
*/
includeFeedback?: boolean;
/**
* Add Sentry Metro Server Middleware which
* enables the app to fetch stack frames source context.
Expand Down Expand Up @@ -79,6 +85,7 @@ export function withSentryConfig(
{
annotateReactComponents = false,
includeWebReplay = true,
includeFeedback = true,
enableSourceContextInDevelopment = true,
optionsFile = true,
}: SentryMetroConfigOptions = {},
Expand All @@ -95,6 +102,9 @@ export function withSentryConfig(
if (includeWebReplay === false) {
newConfig = withSentryResolver(newConfig, includeWebReplay);
}
if (includeFeedback === false) {
newConfig = withSentryFeedbackResolver(newConfig, includeFeedback);
}
newConfig = withSentryExcludeServerOnlyResolver(newConfig);
if (enableSourceContextInDevelopment) {
newConfig = withSentryMiddleware(newConfig);
Expand Down Expand Up @@ -135,6 +145,9 @@ export function getSentryExpoConfig(
if (options.includeWebReplay === false) {
newConfig = withSentryResolver(newConfig, options.includeWebReplay);
}
if (options.includeFeedback === false) {
newConfig = withSentryFeedbackResolver(newConfig, options.includeFeedback);
}
newConfig = withSentryExcludeServerOnlyResolver(newConfig);

if (options.enableSourceContextInDevelopment ?? true) {
Expand Down Expand Up @@ -230,21 +243,26 @@ type CustomResolverBeforeMetro068 = (
) => Resolution;

/**
* Includes `@sentry/replay` packages based on the `includeWebReplay` flag and current bundle `platform`.
* Builds a Metro resolver that returns `{ type: 'empty' }` for Sentry sub-packages
* matching `moduleRegex` when the user opts out on web or the platform is native.
*/
export function withSentryResolver(config: MetroConfig, includeWebReplay: boolean | undefined): MetroConfig {
function buildSentryPackageExcludeResolver(
config: MetroConfig,
includePackage: boolean | undefined,
moduleRegex: RegExp,
optionName: string,
): MetroConfig {
const originalResolver = config.resolver?.resolveRequest as CustomResolver | CustomResolverBeforeMetro068 | undefined;

const sentryResolverRequest: CustomResolver = (
const resolverRequest: CustomResolver = (
context: CustomResolutionContext,
moduleName: string,
platform: string | null,
oldMetroModuleName?: string,
) => {
if (
(includeWebReplay === false ||
(includeWebReplay === undefined && (platform === 'android' || platform === 'ios'))) &&
!!(oldMetroModuleName ?? moduleName).match(/@sentry(?:-internal)?\/replay/)
(includePackage === false || (includePackage === undefined && (platform === 'android' || platform === 'ios'))) &&
!!(oldMetroModuleName ?? moduleName).match(moduleRegex)
) {
return { type: 'empty' } as Resolution;
}
Expand All @@ -254,15 +272,15 @@ export function withSentryResolver(config: MetroConfig, includeWebReplay: boolea
: originalResolver(context, moduleName, platform);
}

// Prior 0.68, resolve context.resolveRequest is sentryResolver itself, where on later version it is the default resolver.
if (context.resolveRequest === sentryResolverRequest) {
// Prior 0.68, context.resolveRequest is resolverRequest itself, where on later version it is the default resolver.
if (context.resolveRequest === resolverRequest) {
// oxlint-disable-next-line eslint(no-console)
console.error(
`Error: [@sentry/react-native/metro] Can not resolve the defaultResolver on Metro older than 0.68.
Please follow one of the following options:
- Include your resolverRequest on your metroconfig.
- Update your Metro version to 0.68 or higher.
- Set includeWebReplay as true on your metro config.
- Set ${optionName} as true on your metro config.
- If you are still facing issues, report the issue at http://www.github.com/getsentry/sentry-react-native/issues`,
);
// Return required for test.
Expand All @@ -276,11 +294,35 @@ Please follow one of the following options:
...config,
resolver: {
...config.resolver,
resolveRequest: sentryResolverRequest,
resolveRequest: resolverRequest,
},
};
}

/**
* Includes `@sentry/replay` packages based on the `includeWebReplay` flag and current bundle `platform`.
*/
export function withSentryResolver(config: MetroConfig, includeWebReplay: boolean | undefined): MetroConfig {
return buildSentryPackageExcludeResolver(
config,
includeWebReplay,
/@sentry(?:-internal)?\/replay/,
'includeWebReplay',
);
}

/**
* Includes `@sentry-internal/feedback` packages based on the `includeFeedback` flag and current bundle `platform`.
*/
export function withSentryFeedbackResolver(config: MetroConfig, includeFeedback: boolean | undefined): MetroConfig {
return buildSentryPackageExcludeResolver(
config,
includeFeedback,
/@sentry(?:-internal)?\/feedback/,
'includeFeedback',
);
}

/**
* Matches relative import paths to server-only AI/MCP modules within `@sentry/core`.
*
Expand Down
185 changes: 185 additions & 0 deletions packages/core/test/tools/metroconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getSentryExpoConfig,
withSentryBabelTransformer,
withSentryExcludeServerOnlyResolver,
withSentryFeedbackResolver,
withSentryFramesCollapsed,
withSentryResolver,
} from '../../src/js/tools/metroconfig';
Expand Down Expand Up @@ -364,6 +365,190 @@ describe('metroconfig', () => {
}
});
});
describe('withSentryFeedbackResolver', () => {
let originalResolverMock: any;

// @ts-expect-error Can't see type CustomResolutionContext
let contextMock: CustomResolutionContext;
let config: MetroConfig = {};

beforeEach(() => {
originalResolverMock = jest.fn();
contextMock = {
resolveRequest: jest.fn(),
};

config = {
resolver: {
resolveRequest: originalResolverMock,
},
};
});

describe.each([
['new Metro', false, '0.70.0'],
['old Metro', true, '0.67.0'],
])('on %s', (_description, oldMetro, metroVersion) => {
beforeEach(() => {
jest.resetModules();
// Mock metro/package.json
jest.mock('metro/package.json', () => ({
version: metroVersion,
}));
});

describe.each([['@sentry-internal/feedback'], ['@sentry/feedback']])('with %s', feedbackPackage => {
test('keep Feedback when platform is web and includeFeedback is true', () => {
const modifiedConfig = withSentryFeedbackResolver(config, true);
resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'web');

ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, feedbackPackage, 'web');
});

test('removes Feedback when platform is web and includeFeedback is false', () => {
const modifiedConfig = withSentryFeedbackResolver(config, false);
const result = resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'web');

expect(result).toEqual({ type: 'empty' });
expect(originalResolverMock).not.toHaveBeenCalled();
});

test('keep Feedback when platform is android and includeFeedback is true', () => {
const modifiedConfig = withSentryFeedbackResolver(config, true);
resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'android');

ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, feedbackPackage, 'android');
});

test('removes Feedback when platform is android and includeFeedback is false', () => {
const modifiedConfig = withSentryFeedbackResolver(config, false);
const result = resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'android');

expect(result).toEqual({ type: 'empty' });
expect(originalResolverMock).not.toHaveBeenCalled();
});

test('removes Feedback when platform is android and includeFeedback is undefined', () => {
const modifiedConfig = withSentryFeedbackResolver(config, undefined);
const result = resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'android');

expect(result).toEqual({ type: 'empty' });
expect(originalResolverMock).not.toHaveBeenCalled();
});

test('keep Feedback when platform is undefined and includeFeedback is null', () => {
const modifiedConfig = withSentryFeedbackResolver(config, undefined);
resolveRequest(modifiedConfig, contextMock, feedbackPackage, null);

ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, feedbackPackage, null);
});

test('keep Feedback when platform is ios and includeFeedback is true', () => {
const modifiedConfig = withSentryFeedbackResolver(config, true);
resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'ios');

ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, feedbackPackage, 'ios');
});

test('removes Feedback when platform is ios and includeFeedback is false', () => {
const modifiedConfig = withSentryFeedbackResolver(config, false);
const result = resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'ios');

expect(result).toEqual({ type: 'empty' });
expect(originalResolverMock).not.toHaveBeenCalled();
});

test('removes Feedback when platform is ios and includeFeedback is undefined', () => {
const modifiedConfig = withSentryFeedbackResolver(config, undefined);
const result = resolveRequest(modifiedConfig, contextMock, feedbackPackage, 'ios');

expect(result).toEqual({ type: 'empty' });
expect(originalResolverMock).not.toHaveBeenCalled();
});
});

test('calls originalResolver when moduleName is not @sentry-internal/feedback', () => {
const modifiedConfig = withSentryFeedbackResolver(config, true);
const moduleName = 'some/other/module';
resolveRequest(modifiedConfig, contextMock, moduleName, 'web');

ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, moduleName, 'web');
});

test('calls originalResolver when moduleName is not @sentry-internal/feedback and includeFeedback set to false', () => {
const modifiedConfig = withSentryFeedbackResolver(config, false);
const moduleName = 'some/other/module';
resolveRequest(modifiedConfig, contextMock, moduleName, 'web');

ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, moduleName, 'web');
});

test('calls default resolver on new metro resolver when originalResolver is not provided', () => {
if (oldMetro) {
return;
}

const modifiedConfig = withSentryFeedbackResolver({ resolver: {} }, true);
const moduleName = 'some/other/module';
const platform = 'web';
resolveRequest(modifiedConfig, contextMock, moduleName, platform);

ExpectToBeCalledWithMetroParameters(contextMock.resolveRequest, contextMock, moduleName, platform);
});

test('throws error when running on old metro and includeFeedback is set to false', () => {
if (!oldMetro) {
return;
}

// @ts-expect-error mock.
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const modifiedConfig = withSentryFeedbackResolver({ resolver: {} }, true);
const moduleName = 'some/other/module';
resolveRequest(modifiedConfig, contextMock, moduleName, 'web');

expect(mockExit).toHaveBeenCalledWith(-1);
});

type CustomResolverBeforeMetro067 = (
// @ts-expect-error Can't see type CustomResolutionContext
context: CustomResolutionContext,
realModuleName: string,
platform: string | null,
moduleName?: string,
// @ts-expect-error Can't see type CustomResolutionContext
) => Resolution;

function resolveRequest(
metroConfig: MetroConfig,
context: any,
moduleName: string,
platform: string | null,
// @ts-expect-error Can't see type Resolution.
): Resolution {
if (oldMetro) {
const resolver = metroConfig.resolver?.resolveRequest as CustomResolverBeforeMetro067;
// On older Metro the resolveRequest is the creater resolver.
context.resolveRequest = resolver;
return resolver(context, `real${moduleName}`, platform, moduleName);
}
return metroConfig.resolver?.resolveRequest?.(context, moduleName, platform);
}

function ExpectToBeCalledWithMetroParameters(
received: CustomResolverBeforeMetro067,
contextMock: CustomResolverBeforeMetro067,
moduleName: string,
platform: string | null,
) {
if (oldMetro) {
expect(received).toHaveBeenCalledWith(contextMock, `real${moduleName}`, platform, moduleName);
} else {
expect(received).toHaveBeenCalledWith(contextMock, moduleName, platform);
}
}
});
});
describe('withSentryExcludeServerOnlyResolver', () => {
const SENTRY_CORE_ORIGIN = '/project/node_modules/@sentry/core/build/esm/index.js';

Expand Down
Loading