diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1726e96c..3d31a09c69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ ### Fixes - Defer initial navigation span creation until navigation container is registered ([#5789](https://github.com/getsentry/sentry-react-native/pull/5789)) +- Exclude server-only AI/MCP modules from native bundles, reducing bundle size by ~150kb ([#5802](https://github.com/getsentry/sentry-react-native/pull/5802)) ### Dependencies diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 14dbef5d51..46bb0aa300 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -91,6 +91,7 @@ export function withSentryConfig( if (includeWebReplay === false) { newConfig = withSentryResolver(newConfig, includeWebReplay); } + newConfig = withSentryExcludeServerOnlyResolver(newConfig); if (enableSourceContextInDevelopment) { newConfig = withSentryMiddleware(newConfig); } @@ -128,6 +129,7 @@ export function getSentryExpoConfig( if (options.includeWebReplay === false) { newConfig = withSentryResolver(newConfig, options.includeWebReplay); } + newConfig = withSentryExcludeServerOnlyResolver(newConfig); if (options.enableSourceContextInDevelopment ?? true) { newConfig = withSentryMiddleware(newConfig); @@ -274,6 +276,69 @@ Please follow one of the following options: }; } +/** + * Matches relative import paths to server-only AI/MCP modules within `@sentry/core`. + * + * Metro passes the module name as-written in the source code, so for imports inside + * `@sentry/core`'s barrel file like `export { ... } from './integrations/mcp-server/index.js'`, + * the `moduleName` will be `./integrations/mcp-server/index.js`. + */ +const SERVER_ONLY_MODULE_RE = + /\/(mcp-server|tracing\/(vercel-ai|openai|anthropic-ai|google-genai|langchain|langgraph)|utils\/ai)(\/|$)/; + +function isFromSentryCore(originModulePath: string): boolean { + return originModulePath.includes('@sentry/core'); +} + +/** + * Excludes server-only AI/MCP modules from native (Android/iOS) bundles. + */ +export function withSentryExcludeServerOnlyResolver(config: MetroConfig): MetroConfig { + const originalResolver = config.resolver?.resolveRequest as CustomResolver | CustomResolverBeforeMetro068 | undefined; + + const sentryServerOnlyResolverRequest: CustomResolver = ( + context: CustomResolutionContext, + moduleName: string, + platform: string | null, + oldMetroModuleName?: string, + ) => { + if ( + (platform === 'android' || platform === 'ios') && + isFromSentryCore((context as { originModulePath?: string }).originModulePath ?? '') && + SERVER_ONLY_MODULE_RE.test(oldMetroModuleName ?? moduleName) + ) { + return { type: 'empty' } as Resolution; + } + if (originalResolver) { + return oldMetroModuleName + ? originalResolver(context, moduleName, platform, oldMetroModuleName) + : originalResolver(context, moduleName, platform); + } + + // Prior 0.68, context.resolveRequest is sentryServerOnlyResolverRequest itself, which would cause infinite recursion. + if (context.resolveRequest === sentryServerOnlyResolverRequest) { + // eslint-disable-next-line no-console + console.error( + `Error: [@sentry/react-native/metro] Can not resolve the defaultResolver on Metro older than 0.68. +Please include your resolverRequest on your metroconfig or update your Metro version to 0.68 or higher. +If you are still facing issues, report the issue at http://www.github.com/getsentry/sentry-react-native/issues`, + ); + // Return required for test. + return process.exit(-1); + } + + return context.resolveRequest(context, moduleName, platform); + }; + + return { + ...config, + resolver: { + ...config.resolver, + resolveRequest: sentryServerOnlyResolverRequest, + }, + }; +} + type MetroFrame = Parameters['symbolicator']>['customizeFrame']>[0]; type MetroCustomizeFrame = { readonly collapse?: boolean }; type MetroCustomizeFrameReturnValue = diff --git a/packages/core/test/tools/metroconfig.test.ts b/packages/core/test/tools/metroconfig.test.ts index 18177fb71e..685dcb0fcb 100644 --- a/packages/core/test/tools/metroconfig.test.ts +++ b/packages/core/test/tools/metroconfig.test.ts @@ -5,6 +5,7 @@ import type { SentryExpoConfigOptions } from '../../src/js/tools/metroconfig'; import { getSentryExpoConfig, withSentryBabelTransformer, + withSentryExcludeServerOnlyResolver, withSentryFramesCollapsed, withSentryResolver, } from '../../src/js/tools/metroconfig'; @@ -362,6 +363,118 @@ describe('metroconfig', () => { } }); }); + describe('withSentryExcludeServerOnlyResolver', () => { + const SENTRY_CORE_ORIGIN = '/project/node_modules/@sentry/core/build/esm/index.js'; + + 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(), + originModulePath: SENTRY_CORE_ORIGIN, + }; + + config = { + resolver: { + resolveRequest: originalResolverMock, + }, + }; + }); + + describe.each([ + ['./integrations/mcp-server/index.js'], + ['./tracing/openai/index.js'], + ['./tracing/anthropic-ai/index.js'], + ['./tracing/google-genai/index.js'], + ['./tracing/vercel-ai/index.js'], + ['./tracing/langchain/index.js'], + ['./tracing/langgraph/index.js'], + ['./utils/ai/providerSkip.js'], + ])('with server-only module %s from @sentry/core', serverOnlyModule => { + test('removes module when platform is android', () => { + const modifiedConfig = withSentryExcludeServerOnlyResolver(config); + const result = modifiedConfig.resolver?.resolveRequest?.(contextMock, serverOnlyModule, 'android'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('removes module when platform is ios', () => { + const modifiedConfig = withSentryExcludeServerOnlyResolver(config); + const result = modifiedConfig.resolver?.resolveRequest?.(contextMock, serverOnlyModule, 'ios'); + + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); + + test('keeps module when platform is web', () => { + const modifiedConfig = withSentryExcludeServerOnlyResolver(config); + modifiedConfig.resolver?.resolveRequest?.(contextMock, serverOnlyModule, 'web'); + + expect(originalResolverMock).toHaveBeenCalledWith(contextMock, serverOnlyModule, 'web'); + }); + + test('keeps module when platform is null', () => { + const modifiedConfig = withSentryExcludeServerOnlyResolver(config); + modifiedConfig.resolver?.resolveRequest?.(contextMock, serverOnlyModule, null); + + expect(originalResolverMock).toHaveBeenCalledWith(contextMock, serverOnlyModule, null); + }); + }); + + test('does not exclude modules when origin is not @sentry/core', () => { + const nonSentryContext = { + ...contextMock, + originModulePath: '/project/node_modules/some-other-package/index.js', + }; + const modifiedConfig = withSentryExcludeServerOnlyResolver(config); + modifiedConfig.resolver?.resolveRequest?.(nonSentryContext, './tracing/openai/index.js', 'android'); + + expect(originalResolverMock).toHaveBeenCalledWith(nonSentryContext, './tracing/openai/index.js', 'android'); + }); + + test('does not exclude modules when originModulePath is not available', () => { + const noOriginContext = { + resolveRequest: jest.fn(), + }; + const modifiedConfig = withSentryExcludeServerOnlyResolver(config); + modifiedConfig.resolver?.resolveRequest?.(noOriginContext, './tracing/openai/index.js', 'android'); + + expect(originalResolverMock).toHaveBeenCalledWith(noOriginContext, './tracing/openai/index.js', 'android'); + }); + + test('calls originalResolver for non-AI modules on native platforms', () => { + const modifiedConfig = withSentryExcludeServerOnlyResolver(config); + modifiedConfig.resolver?.resolveRequest?.(contextMock, './exports.js', 'android'); + + expect(originalResolverMock).toHaveBeenCalledWith(contextMock, './exports.js', 'android'); + }); + + test('falls back to context.resolveRequest when no originalResolver', () => { + const modifiedConfig = withSentryExcludeServerOnlyResolver({ resolver: {} }); + modifiedConfig.resolver?.resolveRequest?.(contextMock, './exports.js', 'android'); + + expect(contextMock.resolveRequest).toHaveBeenCalledWith(contextMock, './exports.js', 'android'); + }); + + test('exits process on old Metro when context.resolveRequest is the resolver itself (infinite recursion guard)', () => { + // @ts-expect-error mock. + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); + const modifiedConfig = withSentryExcludeServerOnlyResolver({ resolver: {} }); + + const resolver = modifiedConfig.resolver?.resolveRequest; + // Simulate old Metro behavior where context.resolveRequest === the resolver itself + const oldMetroContext = { resolveRequest: resolver }; + resolver?.(oldMetroContext, './exports.js', 'android'); + + expect(mockExit).toHaveBeenCalledWith(-1); + }); + }); }); // function create mock metro frame