Skip to content

Commit f6956cf

Browse files
authored
fix(core): Exclude server-only AI/MCP modules from native bundles (#5802)
* fix(core): Exclude server-only AI/MCP modules from native bundles
1 parent 3bdce7d commit f6956cf

File tree

3 files changed

+179
-0
lines changed

3 files changed

+179
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
### Fixes
3535

3636
- Defer initial navigation span creation until navigation container is registered ([#5789](https://github.com/getsentry/sentry-react-native/pull/5789))
37+
- Exclude server-only AI/MCP modules from native bundles, reducing bundle size by ~150kb ([#5802](https://github.com/getsentry/sentry-react-native/pull/5802))
3738

3839
### Dependencies
3940

packages/core/src/js/tools/metroconfig.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export function withSentryConfig(
9191
if (includeWebReplay === false) {
9292
newConfig = withSentryResolver(newConfig, includeWebReplay);
9393
}
94+
newConfig = withSentryExcludeServerOnlyResolver(newConfig);
9495
if (enableSourceContextInDevelopment) {
9596
newConfig = withSentryMiddleware(newConfig);
9697
}
@@ -128,6 +129,7 @@ export function getSentryExpoConfig(
128129
if (options.includeWebReplay === false) {
129130
newConfig = withSentryResolver(newConfig, options.includeWebReplay);
130131
}
132+
newConfig = withSentryExcludeServerOnlyResolver(newConfig);
131133

132134
if (options.enableSourceContextInDevelopment ?? true) {
133135
newConfig = withSentryMiddleware(newConfig);
@@ -274,6 +276,69 @@ Please follow one of the following options:
274276
};
275277
}
276278

279+
/**
280+
* Matches relative import paths to server-only AI/MCP modules within `@sentry/core`.
281+
*
282+
* Metro passes the module name as-written in the source code, so for imports inside
283+
* `@sentry/core`'s barrel file like `export { ... } from './integrations/mcp-server/index.js'`,
284+
* the `moduleName` will be `./integrations/mcp-server/index.js`.
285+
*/
286+
const SERVER_ONLY_MODULE_RE =
287+
/\/(mcp-server|tracing\/(vercel-ai|openai|anthropic-ai|google-genai|langchain|langgraph)|utils\/ai)(\/|$)/;
288+
289+
function isFromSentryCore(originModulePath: string): boolean {
290+
return originModulePath.includes('@sentry/core');
291+
}
292+
293+
/**
294+
* Excludes server-only AI/MCP modules from native (Android/iOS) bundles.
295+
*/
296+
export function withSentryExcludeServerOnlyResolver(config: MetroConfig): MetroConfig {
297+
const originalResolver = config.resolver?.resolveRequest as CustomResolver | CustomResolverBeforeMetro068 | undefined;
298+
299+
const sentryServerOnlyResolverRequest: CustomResolver = (
300+
context: CustomResolutionContext,
301+
moduleName: string,
302+
platform: string | null,
303+
oldMetroModuleName?: string,
304+
) => {
305+
if (
306+
(platform === 'android' || platform === 'ios') &&
307+
isFromSentryCore((context as { originModulePath?: string }).originModulePath ?? '') &&
308+
SERVER_ONLY_MODULE_RE.test(oldMetroModuleName ?? moduleName)
309+
) {
310+
return { type: 'empty' } as Resolution;
311+
}
312+
if (originalResolver) {
313+
return oldMetroModuleName
314+
? originalResolver(context, moduleName, platform, oldMetroModuleName)
315+
: originalResolver(context, moduleName, platform);
316+
}
317+
318+
// Prior 0.68, context.resolveRequest is sentryServerOnlyResolverRequest itself, which would cause infinite recursion.
319+
if (context.resolveRequest === sentryServerOnlyResolverRequest) {
320+
// eslint-disable-next-line no-console
321+
console.error(
322+
`Error: [@sentry/react-native/metro] Can not resolve the defaultResolver on Metro older than 0.68.
323+
Please include your resolverRequest on your metroconfig or update your Metro version to 0.68 or higher.
324+
If you are still facing issues, report the issue at http://www.github.com/getsentry/sentry-react-native/issues`,
325+
);
326+
// Return required for test.
327+
return process.exit(-1);
328+
}
329+
330+
return context.resolveRequest(context, moduleName, platform);
331+
};
332+
333+
return {
334+
...config,
335+
resolver: {
336+
...config.resolver,
337+
resolveRequest: sentryServerOnlyResolverRequest,
338+
},
339+
};
340+
}
341+
277342
type MetroFrame = Parameters<Required<Required<MetroConfig>['symbolicator']>['customizeFrame']>[0];
278343
type MetroCustomizeFrame = { readonly collapse?: boolean };
279344
type MetroCustomizeFrameReturnValue =

packages/core/test/tools/metroconfig.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { SentryExpoConfigOptions } from '../../src/js/tools/metroconfig';
55
import {
66
getSentryExpoConfig,
77
withSentryBabelTransformer,
8+
withSentryExcludeServerOnlyResolver,
89
withSentryFramesCollapsed,
910
withSentryResolver,
1011
} from '../../src/js/tools/metroconfig';
@@ -362,6 +363,118 @@ describe('metroconfig', () => {
362363
}
363364
});
364365
});
366+
describe('withSentryExcludeServerOnlyResolver', () => {
367+
const SENTRY_CORE_ORIGIN = '/project/node_modules/@sentry/core/build/esm/index.js';
368+
369+
let originalResolverMock: any;
370+
371+
// @ts-expect-error Can't see type CustomResolutionContext
372+
let contextMock: CustomResolutionContext;
373+
let config: MetroConfig = {};
374+
375+
beforeEach(() => {
376+
originalResolverMock = jest.fn();
377+
contextMock = {
378+
resolveRequest: jest.fn(),
379+
originModulePath: SENTRY_CORE_ORIGIN,
380+
};
381+
382+
config = {
383+
resolver: {
384+
resolveRequest: originalResolverMock,
385+
},
386+
};
387+
});
388+
389+
describe.each([
390+
['./integrations/mcp-server/index.js'],
391+
['./tracing/openai/index.js'],
392+
['./tracing/anthropic-ai/index.js'],
393+
['./tracing/google-genai/index.js'],
394+
['./tracing/vercel-ai/index.js'],
395+
['./tracing/langchain/index.js'],
396+
['./tracing/langgraph/index.js'],
397+
['./utils/ai/providerSkip.js'],
398+
])('with server-only module %s from @sentry/core', serverOnlyModule => {
399+
test('removes module when platform is android', () => {
400+
const modifiedConfig = withSentryExcludeServerOnlyResolver(config);
401+
const result = modifiedConfig.resolver?.resolveRequest?.(contextMock, serverOnlyModule, 'android');
402+
403+
expect(result).toEqual({ type: 'empty' });
404+
expect(originalResolverMock).not.toHaveBeenCalled();
405+
});
406+
407+
test('removes module when platform is ios', () => {
408+
const modifiedConfig = withSentryExcludeServerOnlyResolver(config);
409+
const result = modifiedConfig.resolver?.resolveRequest?.(contextMock, serverOnlyModule, 'ios');
410+
411+
expect(result).toEqual({ type: 'empty' });
412+
expect(originalResolverMock).not.toHaveBeenCalled();
413+
});
414+
415+
test('keeps module when platform is web', () => {
416+
const modifiedConfig = withSentryExcludeServerOnlyResolver(config);
417+
modifiedConfig.resolver?.resolveRequest?.(contextMock, serverOnlyModule, 'web');
418+
419+
expect(originalResolverMock).toHaveBeenCalledWith(contextMock, serverOnlyModule, 'web');
420+
});
421+
422+
test('keeps module when platform is null', () => {
423+
const modifiedConfig = withSentryExcludeServerOnlyResolver(config);
424+
modifiedConfig.resolver?.resolveRequest?.(contextMock, serverOnlyModule, null);
425+
426+
expect(originalResolverMock).toHaveBeenCalledWith(contextMock, serverOnlyModule, null);
427+
});
428+
});
429+
430+
test('does not exclude modules when origin is not @sentry/core', () => {
431+
const nonSentryContext = {
432+
...contextMock,
433+
originModulePath: '/project/node_modules/some-other-package/index.js',
434+
};
435+
const modifiedConfig = withSentryExcludeServerOnlyResolver(config);
436+
modifiedConfig.resolver?.resolveRequest?.(nonSentryContext, './tracing/openai/index.js', 'android');
437+
438+
expect(originalResolverMock).toHaveBeenCalledWith(nonSentryContext, './tracing/openai/index.js', 'android');
439+
});
440+
441+
test('does not exclude modules when originModulePath is not available', () => {
442+
const noOriginContext = {
443+
resolveRequest: jest.fn(),
444+
};
445+
const modifiedConfig = withSentryExcludeServerOnlyResolver(config);
446+
modifiedConfig.resolver?.resolveRequest?.(noOriginContext, './tracing/openai/index.js', 'android');
447+
448+
expect(originalResolverMock).toHaveBeenCalledWith(noOriginContext, './tracing/openai/index.js', 'android');
449+
});
450+
451+
test('calls originalResolver for non-AI modules on native platforms', () => {
452+
const modifiedConfig = withSentryExcludeServerOnlyResolver(config);
453+
modifiedConfig.resolver?.resolveRequest?.(contextMock, './exports.js', 'android');
454+
455+
expect(originalResolverMock).toHaveBeenCalledWith(contextMock, './exports.js', 'android');
456+
});
457+
458+
test('falls back to context.resolveRequest when no originalResolver', () => {
459+
const modifiedConfig = withSentryExcludeServerOnlyResolver({ resolver: {} });
460+
modifiedConfig.resolver?.resolveRequest?.(contextMock, './exports.js', 'android');
461+
462+
expect(contextMock.resolveRequest).toHaveBeenCalledWith(contextMock, './exports.js', 'android');
463+
});
464+
465+
test('exits process on old Metro when context.resolveRequest is the resolver itself (infinite recursion guard)', () => {
466+
// @ts-expect-error mock.
467+
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
468+
const modifiedConfig = withSentryExcludeServerOnlyResolver({ resolver: {} });
469+
470+
const resolver = modifiedConfig.resolver?.resolveRequest;
471+
// Simulate old Metro behavior where context.resolveRequest === the resolver itself
472+
const oldMetroContext = { resolveRequest: resolver };
473+
resolver?.(oldMetroContext, './exports.js', 'android');
474+
475+
expect(mockExit).toHaveBeenCalledWith(-1);
476+
});
477+
});
365478
});
366479

367480
// function create mock metro frame

0 commit comments

Comments
 (0)