From 37b52a20be6d69ebdab0e648626bb7563a65bec0 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 11 Mar 2026 13:08:09 +0100 Subject: [PATCH 1/5] fix(core): Exclude server-only AI/MCP modules from native bundles Closes #5628 Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/tools/metroconfig.ts | 45 +++++++++++ packages/core/test/tools/metroconfig.test.ts | 78 ++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 14dbef5d51..29910fbbac 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,49 @@ Please follow one of the following options: }; } +const SENTRY_CORE_SERVER_ONLY_MODULE_RE = + /@sentry\/core\/.*\/(mcp-server|tracing\/(vercel-ai|openai|anthropic-ai|google-genai|langchain|langgraph)|utils\/ai)\//; + +/** + * 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') && + SENTRY_CORE_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); + } + + if (context.resolveRequest === sentryServerOnlyResolverRequest) { + return context.resolveRequest(context, moduleName, platform); + } + + 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..66a8a9b60f 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,83 @@ describe('metroconfig', () => { } }); }); + describe('withSentryExcludeServerOnlyResolver', () => { + 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([ + ['@sentry/core/build/esm/integrations/mcp-server/index.js'], + ['@sentry/core/build/esm/tracing/openai/index.js'], + ['@sentry/core/build/esm/tracing/anthropic-ai/index.js'], + ['@sentry/core/build/esm/tracing/google-genai/index.js'], + ['@sentry/core/build/esm/tracing/vercel-ai/index.js'], + ['@sentry/core/build/esm/tracing/langchain/index.js'], + ['@sentry/core/build/esm/tracing/langgraph/index.js'], + ['@sentry/core/build/esm/utils/ai/providerSkip.js'], + ['@sentry/core/build/cjs/integrations/mcp-server/index.js'], + ['@sentry/core/build/cjs/tracing/openai/index.js'], + ])('with server-only module %s', 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('calls originalResolver for non-AI modules on native platforms', () => { + const modifiedConfig = withSentryExcludeServerOnlyResolver(config); + modifiedConfig.resolver?.resolveRequest?.(contextMock, 'some/other/module', 'android'); + + expect(originalResolverMock).toHaveBeenCalledWith(contextMock, 'some/other/module', 'android'); + }); + + test('falls back to context.resolveRequest when no originalResolver', () => { + const modifiedConfig = withSentryExcludeServerOnlyResolver({ resolver: {} }); + modifiedConfig.resolver?.resolveRequest?.(contextMock, 'some/other/module', 'android'); + + expect(contextMock.resolveRequest).toHaveBeenCalledWith(contextMock, 'some/other/module', 'android'); + }); + }); }); // function create mock metro frame From 0baa2198bd62753037f587fb4527cf6db382f7df Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 11 Mar 2026 13:22:46 +0100 Subject: [PATCH 2/5] fix(core): Handle infinite recursion on Metro < 0.68 in server-only resolver Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/tools/metroconfig.ts | 10 +++++++++- packages/core/test/tools/metroconfig.test.ts | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 29910fbbac..d8981b03fc 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -303,8 +303,16 @@ export function withSentryExcludeServerOnlyResolver(config: MetroConfig): MetroC : originalResolver(context, moduleName, platform); } + // Prior 0.68, context.resolveRequest is sentryServerOnlyResolverRequest itself, which would cause infinite recursion. if (context.resolveRequest === sentryServerOnlyResolverRequest) { - return context.resolveRequest(context, moduleName, platform); + // 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); diff --git a/packages/core/test/tools/metroconfig.test.ts b/packages/core/test/tools/metroconfig.test.ts index 66a8a9b60f..9571eb098a 100644 --- a/packages/core/test/tools/metroconfig.test.ts +++ b/packages/core/test/tools/metroconfig.test.ts @@ -439,6 +439,19 @@ describe('metroconfig', () => { expect(contextMock.resolveRequest).toHaveBeenCalledWith(contextMock, 'some/other/module', '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, 'some/other/module', 'android'); + + expect(mockExit).toHaveBeenCalledWith(-1); + }); }); }); From 509718d22f94b3156fc15e08c625af030db924dc Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 11 Mar 2026 13:29:35 +0100 Subject: [PATCH 3/5] fix(core): Make trailing slash optional in server-only module regex Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/tools/metroconfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index d8981b03fc..5fc3fcab62 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -277,7 +277,7 @@ Please follow one of the following options: } const SENTRY_CORE_SERVER_ONLY_MODULE_RE = - /@sentry\/core\/.*\/(mcp-server|tracing\/(vercel-ai|openai|anthropic-ai|google-genai|langchain|langgraph)|utils\/ai)\//; + /@sentry\/core\/.*\/(mcp-server|tracing\/(vercel-ai|openai|anthropic-ai|google-genai|langchain|langgraph)|utils\/ai)(\/|$)/; /** * Excludes server-only AI/MCP modules from native (Android/iOS) bundles. From d855620f17ca7bb11ed396e4740b61faff30327d Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 11 Mar 2026 13:30:38 +0100 Subject: [PATCH 4/5] docs: Add changelog entry for AI/MCP module exclusion Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fb6517ae..8251a773b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,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 From 549b21cad84409c6c0ca5481529eda486b0f20b4 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 11 Mar 2026 16:57:04 +0100 Subject: [PATCH 5/5] fix(core): Fix server-only module resolver to use originModulePath The previous regex matched on full package paths like @sentry/core/.../mcp-server/..., but Metro passes relative paths (./integrations/mcp-server/index.js) as the moduleName. Fix by: 1. Matching on relative path segments in moduleName 2. Using context.originModulePath to scope exclusion to @sentry/core Verified with actual Metro bundles: - Production: 169 modules and ~390KB removed - Dev: 169 modules and ~1.2MB removed Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/tools/metroconfig.ts | 18 +++++-- packages/core/test/tools/metroconfig.test.ts | 54 ++++++++++++++------ 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index 5fc3fcab62..46bb0aa300 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -276,8 +276,19 @@ Please follow one of the following options: }; } -const SENTRY_CORE_SERVER_ONLY_MODULE_RE = - /@sentry\/core\/.*\/(mcp-server|tracing\/(vercel-ai|openai|anthropic-ai|google-genai|langchain|langgraph)|utils\/ai)(\/|$)/; +/** + * 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. @@ -293,7 +304,8 @@ export function withSentryExcludeServerOnlyResolver(config: MetroConfig): MetroC ) => { if ( (platform === 'android' || platform === 'ios') && - SENTRY_CORE_SERVER_ONLY_MODULE_RE.test(oldMetroModuleName ?? moduleName) + isFromSentryCore((context as { originModulePath?: string }).originModulePath ?? '') && + SERVER_ONLY_MODULE_RE.test(oldMetroModuleName ?? moduleName) ) { return { type: 'empty' } as Resolution; } diff --git a/packages/core/test/tools/metroconfig.test.ts b/packages/core/test/tools/metroconfig.test.ts index 9571eb098a..685dcb0fcb 100644 --- a/packages/core/test/tools/metroconfig.test.ts +++ b/packages/core/test/tools/metroconfig.test.ts @@ -364,6 +364,8 @@ 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 @@ -374,6 +376,7 @@ describe('metroconfig', () => { originalResolverMock = jest.fn(); contextMock = { resolveRequest: jest.fn(), + originModulePath: SENTRY_CORE_ORIGIN, }; config = { @@ -384,17 +387,15 @@ describe('metroconfig', () => { }); describe.each([ - ['@sentry/core/build/esm/integrations/mcp-server/index.js'], - ['@sentry/core/build/esm/tracing/openai/index.js'], - ['@sentry/core/build/esm/tracing/anthropic-ai/index.js'], - ['@sentry/core/build/esm/tracing/google-genai/index.js'], - ['@sentry/core/build/esm/tracing/vercel-ai/index.js'], - ['@sentry/core/build/esm/tracing/langchain/index.js'], - ['@sentry/core/build/esm/tracing/langgraph/index.js'], - ['@sentry/core/build/esm/utils/ai/providerSkip.js'], - ['@sentry/core/build/cjs/integrations/mcp-server/index.js'], - ['@sentry/core/build/cjs/tracing/openai/index.js'], - ])('with server-only module %s', serverOnlyModule => { + ['./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'); @@ -426,18 +427,39 @@ describe('metroconfig', () => { }); }); + 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, 'some/other/module', 'android'); + modifiedConfig.resolver?.resolveRequest?.(contextMock, './exports.js', 'android'); - expect(originalResolverMock).toHaveBeenCalledWith(contextMock, 'some/other/module', '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, 'some/other/module', 'android'); + modifiedConfig.resolver?.resolveRequest?.(contextMock, './exports.js', 'android'); - expect(contextMock.resolveRequest).toHaveBeenCalledWith(contextMock, 'some/other/module', '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)', () => { @@ -448,7 +470,7 @@ describe('metroconfig', () => { const resolver = modifiedConfig.resolver?.resolveRequest; // Simulate old Metro behavior where context.resolveRequest === the resolver itself const oldMetroContext = { resolveRequest: resolver }; - resolver?.(oldMetroContext, 'some/other/module', 'android'); + resolver?.(oldMetroContext, './exports.js', 'android'); expect(mockExit).toHaveBeenCalledWith(-1); });