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 @@ -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

Expand Down
65 changes: 65 additions & 0 deletions packages/core/src/js/tools/metroconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function withSentryConfig(
if (includeWebReplay === false) {
newConfig = withSentryResolver(newConfig, includeWebReplay);
}
newConfig = withSentryExcludeServerOnlyResolver(newConfig);
if (enableSourceContextInDevelopment) {
newConfig = withSentryMiddleware(newConfig);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Required<Required<MetroConfig>['symbolicator']>['customizeFrame']>[0];
type MetroCustomizeFrame = { readonly collapse?: boolean };
type MetroCustomizeFrameReturnValue =
Expand Down
113 changes: 113 additions & 0 deletions packages/core/test/tools/metroconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { SentryExpoConfigOptions } from '../../src/js/tools/metroconfig';
import {
getSentryExpoConfig,
withSentryBabelTransformer,
withSentryExcludeServerOnlyResolver,
withSentryFramesCollapsed,
withSentryResolver,
} from '../../src/js/tools/metroconfig';
Expand Down Expand Up @@ -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
Expand Down
Loading