From a09035c6e966d7fc8f7ca821dc86cedf592bec0b Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 21 Oct 2026 10:46:58 +0200 Subject: [PATCH 1/3] fix(core): Stop wrapping RNSentry in TurboModuleContext by default Each wrapped RNSentry call wrote the turbo_module context and two tags to the isolation scope at start and end. Scope sync forwards every write to the native SDK, and sentry-cocoa persists each scope change to disk for watchdog termination tracking. The SDK calls RNSentry about 54 times per tab switch, so this added about 5 ms of CPU per call on iOS. RNSentry is already in the default ignoreTurboModules list, so with default options the wrap only produced crash context for the SDK's own calls. Now RNSentry is wrapped only when the user removes it from ignoreTurboModules, which keeps the opt-in aggregate behavior. Fixes #6886 --- CHANGELOG.md | 2 ++ .../src/js/integrations/turboModuleContext.ts | 15 ++++++--- .../integrations/turboModuleContext.test.ts | 33 +++++++++++++++++-- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 261d6e8b70..20db7aa361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ - Find `ios/.xcode.env` in the Expo plugin "Upload Debug Symbols to Sentry" phase when the iOS project uses Swift Package Manager instead of CocoaPods ([#6911](https://github.com/getsentry/sentry-react-native/pull/6911)) - Remove the `@sentry/browser` feedback modules and the server-only `@sentry/core` modules from native bundles built on Windows. Before this fix, `includeWebFeedback: false` made these bundles fail at launch ([#6915](https://github.com/getsentry/sentry-react-native/pull/6915)) +- Stop wrapping the SDK's own `RNSentry` module in `turboModuleContextIntegration` by default. Each wrapped call wrote to the native scope several times, which added CPU time to every SDK call on iOS ([#PLACEHOLDER](https://github.com/getsentry/sentry-react-native/pull/PLACEHOLDER)) + - To wrap `RNSentry` again, remove it from the `ignoreTurboModules` option. ## 8.31.0 diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index ad37e56a91..94368b3af7 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -49,7 +49,7 @@ export const MAX_PENDING_CALL_WINDOWS = 1024; export const MAX_PENDING_SPAN_ATTRIBUTES = 256; export interface TurboModuleContextOptions { - /** Additional TurboModules to wrap. `RNSentry` is always tracked. */ + /** Additional TurboModules to wrap. `RNSentry` is wrapped only when it is removed from `ignoreTurboModules`. */ modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray }>; /** Per-(module, method, kind) counters, flushed on transaction finish and on a periodic timer. Default: `true`. */ @@ -62,6 +62,8 @@ export interface TurboModuleContextOptions { * Modules opted out of the aggregate (still wrapped for crash context). * Default `['RNSentry']` — the SDK's own transport calls would otherwise * pollute the signal and self-re-arm the periodic timer indefinitely. + * + * `RNSentry` is the exception: while it is in this list, it is not wrapped at all. */ ignoreTurboModules?: ReadonlyArray; @@ -147,9 +149,12 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions return { name: INTEGRATION_NAME, setupOnce() { - // Wrap RNSentry first with its curated skip list; `wrapAllNativeModules` - // then skips it implicitly to avoid double-wrapping with a wider surface. - wrapTurboModule('RNSentry', getRNSentryModule(), { skip: RNSENTRY_SKIP, arch: contextArch }); + const ignoredModules = options.ignoreTurboModules ?? ['RNSentry']; + + // Each wrapped call costs about six native scope writes, which sentry-cocoa persists to disk. + if (!ignoredModules.includes('RNSentry')) { + wrapTurboModule('RNSentry', getRNSentryModule(), { skip: RNSENTRY_SKIP, arch: contextArch }); + } for (const entry of options.modules ?? []) { wrapTurboModule(entry.name, entry.module, { skip: entry.skipMethods, arch: contextArch }); @@ -164,7 +169,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions setAggregateRecordingEnabled(enableAggregate); if (enableAggregate || enableSpanAttribution || slowCallThresholdMs > 0) { - setIgnoredTurboModules(options.ignoreTurboModules ?? ['RNSentry']); + setIgnoredTurboModules(ignoredModules); } }, setup(client: Client): void { diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index f5a73b3fe0..060f501630 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -107,7 +107,34 @@ describe('turboModuleContextIntegration', () => { _resetTurboModuleAggregator(); }); - it('wraps the live RNSentry TurboModule on setup', () => { + it('does not wrap RNSentry by default', () => { + const fakeModule = { fetchNativeFrames: jest.fn() }; + const originalFetchNativeFrames = fakeModule.fetchNativeFrames; + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(fakeModule as never); + const wrapSpy = jest.spyOn(turboModule, 'wrapTurboModule'); + + turboModuleContextIntegration().setupOnce!(); + + expect(wrapSpy).not.toHaveBeenCalledWith('RNSentry', expect.anything(), expect.anything()); + expect(fakeModule.fetchNativeFrames).toBe(originalFetchNativeFrames); + }); + + it('does not write turbo_module scope data for RNSentry calls by default', () => { + const fakeModule = { fetchNativeFrames: jest.fn() }; + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(fakeModule as never); + const isolationScope = new Scope(); + jest.spyOn(SentryCore, 'getIsolationScope').mockReturnValue(isolationScope); + const setContextSpy = jest.spyOn(isolationScope, 'setContext'); + const setTagSpy = jest.spyOn(isolationScope, 'setTag'); + turboModuleContextIntegration().setupOnce!(); + + fakeModule.fetchNativeFrames(); + + expect(setContextSpy).not.toHaveBeenCalled(); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + it('wraps the live RNSentry TurboModule when it is removed from ignoreTurboModules', () => { const fakeModule = { addListener: jest.fn(), removeListeners: jest.fn(), @@ -117,7 +144,7 @@ describe('turboModuleContextIntegration', () => { const wrapSpy = jest.spyOn(turboModule, 'wrapTurboModule'); - turboModuleContextIntegration().setupOnce!(); + turboModuleContextIntegration({ ignoreTurboModules: [] }).setupOnce!(); expect(wrapSpy).toHaveBeenCalledWith( 'RNSentry', @@ -161,7 +188,7 @@ describe('turboModuleContextIntegration', () => { jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(fakeModule as never); const originalCrash = fakeModule.crash; - turboModuleContextIntegration().setupOnce!(); + turboModuleContextIntegration({ ignoreTurboModules: [] }).setupOnce!(); // crash is wrapped (replaced with sentryTurboModuleWrapper, which is a plain // function and therefore lacks the `_isMockFunction` marker the jest mocks carry). From 5c645a6cfcb851d860df2cdf2b7ff97f09eca4e2 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 21 Oct 2026 11:02:34 +0200 Subject: [PATCH 2/3] docs: Add the PR number to the TurboModuleContext changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20db7aa361..fd9be9196b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Find `ios/.xcode.env` in the Expo plugin "Upload Debug Symbols to Sentry" phase when the iOS project uses Swift Package Manager instead of CocoaPods ([#6911](https://github.com/getsentry/sentry-react-native/pull/6911)) - Remove the `@sentry/browser` feedback modules and the server-only `@sentry/core` modules from native bundles built on Windows. Before this fix, `includeWebFeedback: false` made these bundles fail at launch ([#6915](https://github.com/getsentry/sentry-react-native/pull/6915)) -- Stop wrapping the SDK's own `RNSentry` module in `turboModuleContextIntegration` by default. Each wrapped call wrote to the native scope several times, which added CPU time to every SDK call on iOS ([#PLACEHOLDER](https://github.com/getsentry/sentry-react-native/pull/PLACEHOLDER)) +- Stop wrapping the SDK's own `RNSentry` module in `turboModuleContextIntegration` by default. Each wrapped call wrote to the native scope several times, which added CPU time to every SDK call on iOS ([#6921](https://github.com/getsentry/sentry-react-native/pull/6921)) - To wrap `RNSentry` again, remove it from the `ignoreTurboModules` option. ## 8.31.0 From c09b6d94683e5b71024336c2cc470020278b11f6 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 21 Oct 2026 13:58:16 +0200 Subject: [PATCH 3/3] fix(core): Opt in to RNSentry wrapping through the modules option Review feedback on #6921. Two problems with gating the wrap on ignoreTurboModules: - Any user who sets a custom ignore list without RNSentry would turn the wrap back on without knowing it. - Passing RNSentry through modules wrapped it without RNSENTRY_SKIP, so the first SDK call recursed through scope sync until the app crashed. RNSentry is now wrapped only when it is listed in modules. For that entry the integration resolves the module itself when none is given and always merges RNSENTRY_SKIP into the skip list. The module field becomes optional so that { name: 'RNSentry' } is enough. --- CHANGELOG.md | 2 +- packages/core/etc/sentry-react-native.api.md | 2 +- .../src/js/integrations/turboModuleContext.ts | 28 ++++++++-------- .../integrations/turboModuleContext.test.ts | 32 +++++++++++++++++-- samples/react-native/src/App.tsx | 2 +- 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd9be9196b..cb306a6e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ - Find `ios/.xcode.env` in the Expo plugin "Upload Debug Symbols to Sentry" phase when the iOS project uses Swift Package Manager instead of CocoaPods ([#6911](https://github.com/getsentry/sentry-react-native/pull/6911)) - Remove the `@sentry/browser` feedback modules and the server-only `@sentry/core` modules from native bundles built on Windows. Before this fix, `includeWebFeedback: false` made these bundles fail at launch ([#6915](https://github.com/getsentry/sentry-react-native/pull/6915)) - Stop wrapping the SDK's own `RNSentry` module in `turboModuleContextIntegration` by default. Each wrapped call wrote to the native scope several times, which added CPU time to every SDK call on iOS ([#6921](https://github.com/getsentry/sentry-react-native/pull/6921)) - - To wrap `RNSentry` again, remove it from the `ignoreTurboModules` option. + - To wrap `RNSentry` again, add `{ name: 'RNSentry' }` to the `modules` option. ## 8.31.0 diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index f666d3121e..9a4c260b57 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -1259,7 +1259,7 @@ export interface TurboModuleContextOptions { maxTopModulesPerSpan?: number; modules?: Array<{ name: string; - module: object | null | undefined; + module?: object | null; skipMethods?: ReadonlyArray; }>; slowCallThresholdMs?: number; diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts index 94368b3af7..e6fc461dd3 100644 --- a/packages/core/src/js/integrations/turboModuleContext.ts +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -49,8 +49,12 @@ export const MAX_PENDING_CALL_WINDOWS = 1024; export const MAX_PENDING_SPAN_ATTRIBUTES = 256; export interface TurboModuleContextOptions { - /** Additional TurboModules to wrap. `RNSentry` is wrapped only when it is removed from `ignoreTurboModules`. */ - modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray }>; + /** + * TurboModules to wrap. The SDK's own `RNSentry` module is not wrapped by default; + * add `{ name: 'RNSentry' }` to opt in. The SDK resolves the module itself and always + * skips its scope-sync methods. + */ + modules?: Array<{ name: string; module?: object | null; skipMethods?: ReadonlyArray }>; /** Per-(module, method, kind) counters, flushed on transaction finish and on a periodic timer. Default: `true`. */ enableAggregateStats?: boolean; @@ -62,8 +66,6 @@ export interface TurboModuleContextOptions { * Modules opted out of the aggregate (still wrapped for crash context). * Default `['RNSentry']` — the SDK's own transport calls would otherwise * pollute the signal and self-re-arm the periodic timer indefinitely. - * - * `RNSentry` is the exception: while it is in this list, it is not wrapped at all. */ ignoreTurboModules?: ReadonlyArray; @@ -149,15 +151,15 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions return { name: INTEGRATION_NAME, setupOnce() { - const ignoredModules = options.ignoreTurboModules ?? ['RNSentry']; - - // Each wrapped call costs about six native scope writes, which sentry-cocoa persists to disk. - if (!ignoredModules.includes('RNSentry')) { - wrapTurboModule('RNSentry', getRNSentryModule(), { skip: RNSENTRY_SKIP, arch: contextArch }); - } - + // RNSentry is wrapped only on request: each wrapped call costs about six + // native scope writes, which sentry-cocoa persists to disk. for (const entry of options.modules ?? []) { - wrapTurboModule(entry.name, entry.module, { skip: entry.skipMethods, arch: contextArch }); + if (entry.name === 'RNSentry') { + const skip = [...RNSENTRY_SKIP, ...(entry.skipMethods ?? [])]; + wrapTurboModule(entry.name, entry.module ?? getRNSentryModule(), { skip, arch: contextArch }); + } else { + wrapTurboModule(entry.name, entry.module, { skip: entry.skipMethods, arch: contextArch }); + } } if (options.enableLegacyNativeModules === true) { @@ -169,7 +171,7 @@ export const turboModuleContextIntegration = (options: TurboModuleContextOptions setAggregateRecordingEnabled(enableAggregate); if (enableAggregate || enableSpanAttribution || slowCallThresholdMs > 0) { - setIgnoredTurboModules(ignoredModules); + setIgnoredTurboModules(options.ignoreTurboModules ?? ['RNSentry']); } }, setup(client: Client): void { diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts index 060f501630..ea1b2aee87 100644 --- a/packages/core/test/integrations/turboModuleContext.test.ts +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -134,7 +134,17 @@ describe('turboModuleContextIntegration', () => { expect(setTagSpy).not.toHaveBeenCalled(); }); - it('wraps the live RNSentry TurboModule when it is removed from ignoreTurboModules', () => { + it('does not wrap RNSentry when the user passes a custom ignoreTurboModules list', () => { + const fakeModule = { fetchNativeFrames: jest.fn() }; + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(fakeModule as never); + const wrapSpy = jest.spyOn(turboModule, 'wrapTurboModule'); + + turboModuleContextIntegration({ ignoreTurboModules: [] }).setupOnce!(); + + expect(wrapSpy).not.toHaveBeenCalled(); + }); + + it('wraps the live RNSentry TurboModule when it is listed in modules', () => { const fakeModule = { addListener: jest.fn(), removeListeners: jest.fn(), @@ -144,7 +154,7 @@ describe('turboModuleContextIntegration', () => { const wrapSpy = jest.spyOn(turboModule, 'wrapTurboModule'); - turboModuleContextIntegration({ ignoreTurboModules: [] }).setupOnce!(); + turboModuleContextIntegration({ modules: [{ name: 'RNSentry' }] }).setupOnce!(); expect(wrapSpy).toHaveBeenCalledWith( 'RNSentry', @@ -169,6 +179,22 @@ describe('turboModuleContextIntegration', () => { ); }); + it('merges user skipMethods with the RNSentry skip list', () => { + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); + const fakeModule = { crash: jest.fn(), fetchNativeFrames: jest.fn(), setTag: jest.fn() }; + const wrapSpy = jest.spyOn(turboModule, 'wrapTurboModule'); + + turboModuleContextIntegration({ + modules: [{ name: 'RNSentry', module: fakeModule, skipMethods: ['fetchNativeFrames'] }], + }).setupOnce!(); + + expect(wrapSpy).toHaveBeenCalledWith( + 'RNSentry', + fakeModule, + expect.objectContaining({ skip: expect.arrayContaining(['setTag', 'fetchNativeFrames']) }), + ); + }); + it('does not wrap scope-sync methods on RNSentry (would recurse infinitely)', () => { // Sanity check: every method `scopeSync.ts` forwards to NATIVE.* via // RNSentry must be in the skip list, otherwise scope writes recurse. @@ -188,7 +214,7 @@ describe('turboModuleContextIntegration', () => { jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(fakeModule as never); const originalCrash = fakeModule.crash; - turboModuleContextIntegration({ ignoreTurboModules: [] }).setupOnce!(); + turboModuleContextIntegration({ modules: [{ name: 'RNSentry', module: fakeModule }] }).setupOnce!(); // crash is wrapped (replaced with sentryTurboModuleWrapper, which is a plain // function and therefore lacks the `_isMockFunction` marker the jest mocks carry). diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 85aae107d2..67077bd7f3 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -39,7 +39,7 @@ const sampleFeatureFlagsIntegration = Sentry.featureFlagsIntegration(); sampleFeatureFlagsIntegration.addFeatureFlag('sample-test-flag', true); // Replaces the default `TurboModuleContext` integration so the sample's own -// TurboModules are instrumented too, not just `RNSentry`. Exercised by the +// TurboModules are instrumented. Exercised by the // "TurboModule Playground" screen on the Errors tab. const sampleTurboModuleContextIntegration = Sentry.turboModuleContextIntegration({