Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -11,6 +11,7 @@
### Features

- 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 ([#5629](https://github.com/getsentry/sentry-react-native/issues/5629))
Comment thread
antonis marked this conversation as resolved.
Outdated

### Dependencies

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;
Comment thread
lucas-zimerman marked this conversation as resolved.
Outdated
/**
* 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,
include: boolean | undefined,
Comment thread
lucas-zimerman marked this conversation as resolved.
Outdated
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/)
(include === false || (include === 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.
Comment thread
lucas-zimerman marked this conversation as resolved.
- 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