From f43a4f573156917e702de772eb64eae005372cf0 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 28 May 2026 11:16:12 +0200 Subject: [PATCH 1/3] Turbo Modules crash time context --- packages/core/etc/sentry-react-native.api.md | 58 ++++++- packages/core/src/js/index.ts | 9 ++ packages/core/src/js/integrations/default.ts | 6 + packages/core/src/js/integrations/exports.ts | 2 + .../src/js/integrations/turboModuleContext.ts | 50 ++++++ packages/core/src/js/turbomodule/index.ts | 8 + .../src/js/turbomodule/turboModuleTracker.ts | 145 ++++++++++++++++++ .../src/js/turbomodule/wrapTurboModule.ts | 119 ++++++++++++++ .../integrations/turboModuleContext.test.ts | 55 +++++++ .../turbomodule/turboModuleTracker.test.ts | 101 ++++++++++++ .../test/turbomodule/wrapTurboModule.test.ts | 129 ++++++++++++++++ 11 files changed, 675 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/js/integrations/turboModuleContext.ts create mode 100644 packages/core/src/js/turbomodule/index.ts create mode 100644 packages/core/src/js/turbomodule/turboModuleTracker.ts create mode 100644 packages/core/src/js/turbomodule/wrapTurboModule.ts create mode 100644 packages/core/test/integrations/turboModuleContext.test.ts create mode 100644 packages/core/test/turbomodule/turboModuleTracker.test.ts create mode 100644 packages/core/test/turbomodule/wrapTurboModule.test.ts diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index cb69dab67e..a0dbc51603 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -145,7 +145,7 @@ export const appRegistryIntegration: () => Integration & { // // @public export const appStartIntegration: (input?: { - standalone?: boolean; + standalone?: boolean | undefined; }) => AppStartIntegration; export { Breadcrumb } @@ -334,7 +334,7 @@ export { FeedbackForm as FeedbackWidget } export const feedbackIntegration: (initOptions?: Partial & { buttonOptions?: FeedbackButtonProps; screenshotButtonOptions?: ScreenshotButtonProps; - colorScheme?: "system" | "light" | "dark"; + colorScheme?: 'system' | 'light' | 'dark'; themeLight?: Partial; themeDark?: Partial; enableShakeToReport?: boolean; @@ -347,6 +347,9 @@ export { functionToStringIntegration } export { getActiveSpan } +// @public +export function getActiveTurboModuleCall(): TurboModuleCall | undefined; + export { getClient } // Warning: (ae-forgotten-export) The symbol "ReactNativeTracingIntegration" needs to be exported by the entry point index.d.ts @@ -371,6 +374,9 @@ export function getReactNativeTracingIntegration(client: Client): ReactNativeTra export { getRootSpan } +// @public +export function getTurboModuleCallStack(): TurboModuleCall[]; + // Warning: (ae-forgotten-export) The symbol "GlobalErrorBoundaryState" needs to be exported by the entry point index.d.ts // // @public @@ -498,11 +504,22 @@ export { OpenAiOptions } // @public export function pauseAppHangTracking(): void; +// @public +export function popTurboModuleCall(callId: number, scope?: Scope): void; + // @public export const primitiveTagIntegration: () => Integration; export { Profiler } +// @public +export function pushTurboModuleCall(args: { + name: string; + method: string; + kind: 'sync' | 'async'; + scope?: Scope; +}): number; + // Warning: (ae-forgotten-export) The symbol "ReactNativeClientOptions" needs to be exported by the entry point index.d.ts // // @public @@ -633,14 +650,15 @@ export { Stacktrace } // @public export const stallTrackingIntegration: (input?: { - minimumStallThresholdMs?: number; + minimumStallThresholdMs?: number | undefined; }) => Integration; -// Warning: (ae-forgotten-export) The symbol "defaultIdleOptions" needs to be exported by the entry point index.d.ts -// // @public (undocumented) -export const startIdleNavigationSpan: (startSpanOption: StartSpanOptions, input?: Partial & { - isAppRestart?: boolean; +export const startIdleNavigationSpan: (startSpanOption: StartSpanOptions, input?: Partial<{ + idleTimeout: number; + finalTimeout: number; +}> & { + isAppRestart?: boolean | undefined; }) => Span | undefined; // @public @@ -712,6 +730,27 @@ export class TouchEventBoundary extends React_2.Component Integration; + +// @public (undocumented) +export interface TurboModuleContextOptions { + modules?: Array<{ + name: string; + module: object | null | undefined; + skipMethods?: ReadonlyArray; + }>; +} + // @public (undocumented) export const Unmask: HostComponent | React_2.ComponentType; @@ -756,6 +795,11 @@ export function wrapExpoImage(imageClass: T): T; // @public export function wrapExpoRouter(router: T): T; +// @public +export function wrapTurboModule(name: string, module: T | null | undefined, options?: { + skip?: ReadonlyArray; +}): T | null | undefined; + // Warnings were encountered during analysis: // // src/js/feedback/integration.ts:21:5 - (ae-forgotten-export) The symbol "ScreenshotButtonProps" needs to be exported by the entry point index.d.ts diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 46d0d6a6f6..1218235dc5 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -155,3 +155,12 @@ export { FeedbackForm as FeedbackWidget } from './feedback/FeedbackForm'; export { showFeedbackForm as showFeedbackWidget } from './feedback/FeedbackFormManager'; export { getDataFromUri } from './wrapper'; + +export { + getActiveTurboModuleCall, + getTurboModuleCallStack, + popTurboModuleCall, + pushTurboModuleCall, + wrapTurboModule, +} from './turbomodule'; +export type { TurboModuleCall } from './turbomodule'; diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index f91ed4a89c..a7e6c5f487 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -41,6 +41,7 @@ import { spotlightIntegration, stallTrackingIntegration, timeToDisplayIntegration, + turboModuleContextIntegration, userInteractionIntegration, viewHierarchyIntegration, } from './exports'; @@ -172,5 +173,10 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(primitiveTagIntegration()); + if (options.enableNative) { + // Attribute native crashes to the active TurboModule method (see #6163). + integrations.push(turboModuleContextIntegration()); + } + return integrations; } diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index 4319f6843e..1630272bb4 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -26,6 +26,8 @@ export { appRegistryIntegration } from './appRegistry'; export { timeToDisplayIntegration } from '../tracing/integrations/timeToDisplayIntegration'; export { breadcrumbsIntegration } from './breadcrumbs'; export { primitiveTagIntegration } from './primitiveTagIntegration'; +export { turboModuleContextIntegration } from './turboModuleContext'; +export type { TurboModuleContextOptions } from './turboModuleContext'; export { logEnricherIntegration } from './logEnricherIntegration'; export { graphqlIntegration } from './graphql'; export { supabaseIntegration } from './supabase'; diff --git a/packages/core/src/js/integrations/turboModuleContext.ts b/packages/core/src/js/integrations/turboModuleContext.ts new file mode 100644 index 0000000000..aa2d36d0bc --- /dev/null +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -0,0 +1,50 @@ +import type { Integration } from '@sentry/core'; + +import { wrapTurboModule } from '../turbomodule'; +import { getRNSentryModule } from '../wrapper'; + +export const INTEGRATION_NAME = 'TurboModuleContext'; + +export interface TurboModuleContextOptions { + /** + * Additional TurboModules to track. Each entry's methods will be wrapped so + * that any native crash happening inside a method call gets `contexts.turbo_module` + * + `turbo_module.name` / `turbo_module.method` attached to the crash report. + * + * The built-in `RNSentry` TurboModule is always tracked. + */ + modules?: Array<{ name: string; module: object | null | undefined; skipMethods?: ReadonlyArray }>; +} + +// `addListener` / `removeListeners` are RN event-emitter stubs that fire on +// every subscriber registration — tracking them would just churn the scope. +const RNSENTRY_SKIP = ['addListener', 'removeListeners'] as const; + +/** + * Attaches the currently-executing TurboModule method to the Sentry scope so + * that native crashes can be attributed to the high-level RN module + method + * (e.g. `RNSentry.captureEnvelope`) on top of the native stack trace. + * + * The active call is mirrored as `contexts.turbo_module` and the + * `turbo_module.name` / `turbo_module.method` tags, both of which are already + * synced to the native SDKs by the existing scope-sync hooks and therefore end + * up in crash reports captured by sentry-cocoa / sentry-java. + * + * See https://github.com/getsentry/sentry-react-native/issues/6163. + */ +export const turboModuleContextIntegration = (options: TurboModuleContextOptions = {}): Integration => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // Wrap the live RNSentry TurboModule. Other integrations import the same + // instance by reference, so wrapping here transparently tracks every call + // made from JS — including the SDK's own internal envelope/scope sync + // calls, which are the most likely entry points for native crashes. + wrapTurboModule('RNSentry', getRNSentryModule(), { skip: RNSENTRY_SKIP }); + + for (const entry of options.modules ?? []) { + wrapTurboModule(entry.name, entry.module, { skip: entry.skipMethods }); + } + }, + }; +}; diff --git a/packages/core/src/js/turbomodule/index.ts b/packages/core/src/js/turbomodule/index.ts new file mode 100644 index 0000000000..f75620a1b2 --- /dev/null +++ b/packages/core/src/js/turbomodule/index.ts @@ -0,0 +1,8 @@ +export { + getActiveTurboModuleCall, + getTurboModuleCallStack, + popTurboModuleCall, + pushTurboModuleCall, +} from './turboModuleTracker'; +export type { TurboModuleCall } from './turboModuleTracker'; +export { wrapTurboModule } from './wrapTurboModule'; diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts new file mode 100644 index 0000000000..295a6af852 --- /dev/null +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -0,0 +1,145 @@ +import type { Scope } from '@sentry/core'; + +import { getCurrentScope } from '@sentry/core'; + +/** + * Describes a single TurboModule method invocation currently in flight. + */ +export interface TurboModuleCall { + /** TurboModule name, e.g. `RNSentry`. */ + name: string; + /** Method name, e.g. `captureEnvelope`. */ + method: string; + /** Whether the invocation is `sync` (blocking) or `async` (returns a Promise). */ + kind: 'sync' | 'async'; + /** `Date.now()` at the moment the call started. */ + startedAtMs: number; + /** Monotonically increasing id, used as the JS-side `call_id` cross-reference. */ + callId: number; +} + +const CONTEXT_KEY = 'turbo_module'; +const TAG_NAME = 'turbo_module.name'; +const TAG_METHOD = 'turbo_module.method'; + +let nextCallId = 0; + +/** + * Stack of active TurboModule invocations. + * + * React Native's TurboModule perf logger fires `syncMethodCallStart/End` and + * `asyncMethodCallExecutionStart/End` from the thread executing the C++ method. + * In JS-land we don't have per-OS-thread storage, but the JS thread is single + * threaded — so a single shared stack faithfully models the active call chain + * for everything dispatched from JS. + * + * NOTE: This is an in-memory mirror only. For true async-signal-safety on the + * native crash path we'd want to also write a fixed-size ring buffer of + * `{module_id, method_id}` indexes into shared storage that sentry-cocoa / + * sentry-java can read from a signal handler. The current implementation relies + * on the native SDKs' existing scope mirroring (which serialises `contexts` and + * `tags` for crash reports) — this covers crashes that happen *after* the + * scope update is flushed but is not strictly async-signal-safe. + */ +const stack: TurboModuleCall[] = []; + +/** + * Returns the active TurboModule call (top of stack), or `undefined` if no + * TurboModule call is currently being tracked. + */ +export function getActiveTurboModuleCall(): TurboModuleCall | undefined { + return stack[stack.length - 1]; +} + +/** + * Returns a copy of the current TurboModule call stack, top-most call last. + * Exposed for tests and diagnostics. + */ +export function getTurboModuleCallStack(): TurboModuleCall[] { + return stack.slice(); +} + +/** + * Resets the tracker. Tests only. + */ +export function _resetTurboModuleTracker(): void { + stack.length = 0; + nextCallId = 0; +} + +/** + * Records the start of a TurboModule method invocation and mirrors it onto the + * current Sentry scope so that any crash report captured during the call + * carries `contexts.turbo_module` + `turbo_module.*` tags. + * + * Returns the assigned `callId`, to be passed back into {@link popTurboModuleCall}. + */ +export function pushTurboModuleCall(args: { + name: string; + method: string; + kind: 'sync' | 'async'; + scope?: Scope; +}): number { + const call: TurboModuleCall = { + name: args.name, + method: args.method, + kind: args.kind, + startedAtMs: Date.now(), + callId: nextCallId++, + }; + + stack.push(call); + syncToScope(call, args.scope); + return call.callId; +} + +/** + * Records the end of a TurboModule method invocation previously started with + * {@link pushTurboModuleCall}. Pops the matching frame off the stack and + * updates the Sentry scope to point at the new top (or clears the context if + * the stack is now empty). + * + * `callId` is the value returned by `pushTurboModuleCall`. If the call cannot + * be found (e.g. due to a misuse / race), the pop is a no-op. + */ +export function popTurboModuleCall(callId: number, scope?: Scope): void { + // The common case is a perfectly nested LIFO — pop from the end. + const top = stack[stack.length - 1]; + if (top?.callId === callId) { + stack.pop(); + } else { + // Out-of-order completion (async). Find and splice. + const index = stack.findIndex(c => c.callId === callId); + if (index < 0) { + return; + } + stack.splice(index, 1); + } + + const newTop = stack[stack.length - 1]; + if (newTop) { + syncToScope(newTop, scope); + } else { + clearScope(scope); + } +} + +function syncToScope(call: TurboModuleCall, scope?: Scope): void { + const target = scope ?? getCurrentScope(); + target.setContext(CONTEXT_KEY, { + name: call.name, + method: call.method, + kind: call.kind, + started_at_ms: call.startedAtMs, + call_id: call.callId, + }); + target.setTag(TAG_NAME, call.name); + target.setTag(TAG_METHOD, call.method); +} + +function clearScope(scope?: Scope): void { + const target = scope ?? getCurrentScope(); + target.setContext(CONTEXT_KEY, null); + target.setTag(TAG_NAME, undefined); + target.setTag(TAG_METHOD, undefined); +} diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts new file mode 100644 index 0000000000..216bedad74 --- /dev/null +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -0,0 +1,119 @@ +import { logger } from '@sentry/react'; + +import { popTurboModuleCall, pushTurboModuleCall } from './turboModuleTracker'; + +const WRAPPED_FLAG = '__sentryTurboModuleWrapped__'; + +/** + * Marker added to wrapped modules so we never double-wrap (which would push the + * same call twice onto the tracker stack). + */ +interface MaybeWrapped { + [WRAPPED_FLAG]?: boolean; +} + +/** + * Wraps every function-valued property on the given TurboModule so that each + * invocation is recorded on the Sentry TurboModule tracker. Returns the same + * `module` reference for chaining convenience. + * + * - Sync methods are tracked as `kind: 'sync'` and popped right after the call. + * - Async methods (those returning a thenable) are tracked as `kind: 'async'` + * and popped when the returned promise settles. + * + * `skip` can be used to opt specific method names out of tracking (e.g. very + * hot, no-op methods like RN's `addListener`/`removeListeners` event-emitter + * stubs which would otherwise pollute the scope). + */ +export function wrapTurboModule( + name: string, + module: T | null | undefined, + options: { skip?: ReadonlyArray } = {}, +): T | null | undefined { + if (!module) { + return module; + } + + const maybeWrapped = module as T & MaybeWrapped; + if (maybeWrapped[WRAPPED_FLAG]) { + return module; + } + + const skip = new Set(options.skip ?? []); + + const target = module as unknown as Record; + for (const key of Object.keys(target)) { + if (skip.has(key)) { + continue; + } + const original = target[key]; + if (typeof original !== 'function') { + continue; + } + const originalFn = original as (...a: unknown[]) => unknown; + + target[key] = function sentryTurboModuleWrapper(this: unknown, ...args: unknown[]): unknown { + // We don't know yet whether `original` is sync or async — start optimistic + // as sync, upgrade the scope context if the result is thenable. + const callId = pushTurboModuleCall({ name, method: key, kind: 'sync' }); + let result: unknown; + try { + result = originalFn.apply(this, args); + } catch (e) { + popTurboModuleCall(callId); + throw e; + } + + if (isThenable(result)) { + // Re-record as async — clearer in the report. We just overwrite the + // existing tracker frame in place by popping + re-pushing with a fresh + // id would lose ordering, so instead we leave the stack frame alone + // and only relabel for the scope on completion (it's the *active* + // call's `kind` that ends up in `contexts.turbo_module`, and the + // outer perf-logger driven users can push with `kind: 'async'` + // directly when they know up front). + return (result as Promise).then( + value => { + popTurboModuleCall(callId); + return value; + }, + err => { + popTurboModuleCall(callId); + throw err; + }, + ); + } + + popTurboModuleCall(callId); + return result; + }; + } + + try { + Object.defineProperty(module, WRAPPED_FLAG, { + value: true, + enumerable: false, + configurable: false, + writable: false, + }); + } catch (e) { + // Some TurboModule proxies are sealed — that's fine, we still patched the + // methods, but a second wrap call would be a no-op anyway because the + // properties now point at our wrappers (re-wrapping would still push + // through to `original` which is itself a wrapper, but the per-call + // pushes would double up). Log so this is visible during development. + logger.warn( + `[TurboModuleTracker] Could not mark ${name} as wrapped — repeated wrapping would double-track invocations.`, + ); + } + + return module; +} + +function isThenable(value: unknown): value is PromiseLike { + if (!value || (typeof value !== 'object' && typeof value !== 'function')) { + return false; + } + const then = (value as { then?: unknown }).then; + return typeof then === 'function'; +} diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts new file mode 100644 index 0000000000..b98214fb5e --- /dev/null +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -0,0 +1,55 @@ +import { Scope } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; + +import { turboModuleContextIntegration } from '../../src/js/integrations/turboModuleContext'; +import * as turboModule from '../../src/js/turbomodule'; +import * as wrapper from '../../src/js/wrapper'; + +describe('turboModuleContextIntegration', () => { + let scope: Scope; + + beforeEach(() => { + scope = new Scope(); + jest.spyOn(SentryCore, 'getCurrentScope').mockReturnValue(scope); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('wraps the live RNSentry TurboModule on setup', () => { + const fakeModule = { + addListener: jest.fn(), + removeListeners: jest.fn(), + crash: jest.fn(), + }; + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(fakeModule as never); + + const wrapSpy = jest.spyOn(turboModule, 'wrapTurboModule'); + + turboModuleContextIntegration().setupOnce!(); + + expect(wrapSpy).toHaveBeenCalledWith('RNSentry', fakeModule, { + skip: ['addListener', 'removeListeners'], + }); + }); + + it('wraps additional modules supplied via options', () => { + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); + + const fakeOther = { run: jest.fn() }; + const wrapSpy = jest.spyOn(turboModule, 'wrapTurboModule'); + + turboModuleContextIntegration({ + modules: [{ name: 'Other', module: fakeOther, skipMethods: ['ignored'] }], + }).setupOnce!(); + + expect(wrapSpy).toHaveBeenCalledWith('Other', fakeOther, { skip: ['ignored'] }); + }); + + it('tolerates a missing RNSentry module', () => { + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(undefined); + + expect(() => turboModuleContextIntegration().setupOnce!()).not.toThrow(); + }); +}); diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts new file mode 100644 index 0000000000..e42bb4fab2 --- /dev/null +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -0,0 +1,101 @@ +import { Scope } from '@sentry/core'; + +import { + _resetTurboModuleTracker, + getActiveTurboModuleCall, + getTurboModuleCallStack, + popTurboModuleCall, + pushTurboModuleCall, +} from '../../src/js/turbomodule/turboModuleTracker'; + +describe('turboModuleTracker', () => { + let scope: Scope; + + beforeEach(() => { + _resetTurboModuleTracker(); + scope = new Scope(); + }); + + it('starts empty', () => { + expect(getActiveTurboModuleCall()).toBeUndefined(); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('pushes a call and exposes it on the scope', () => { + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'async', scope }); + + const active = getActiveTurboModuleCall(); + expect(active).toMatchObject({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + callId: id, + }); + expect(typeof active!.startedAtMs).toBe('number'); + + const ctx = scope.getScopeData().contexts.turbo_module; + expect(ctx).toMatchObject({ + name: 'RNSentry', + method: 'captureEnvelope', + kind: 'async', + call_id: id, + }); + expect(scope.getScopeData().tags).toMatchObject({ + 'turbo_module.name': 'RNSentry', + 'turbo_module.method': 'captureEnvelope', + }); + }); + + it('clears the scope when the stack drains', () => { + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'crash', kind: 'sync', scope }); + popTurboModuleCall(id, scope); + + expect(getActiveTurboModuleCall()).toBeUndefined(); + expect(scope.getScopeData().contexts.turbo_module).toBeUndefined(); + expect(scope.getScopeData().tags['turbo_module.name']).toBeUndefined(); + expect(scope.getScopeData().tags['turbo_module.method']).toBeUndefined(); + }); + + it('exposes the new top of stack after popping a nested call', () => { + const outer = pushTurboModuleCall({ name: 'RNSentry', method: 'outer', kind: 'sync', scope }); + const inner = pushTurboModuleCall({ name: 'RNSentry', method: 'inner', kind: 'sync', scope }); + + expect(scope.getScopeData().tags['turbo_module.method']).toBe('inner'); + + popTurboModuleCall(inner, scope); + + expect(getActiveTurboModuleCall()?.callId).toBe(outer); + expect(scope.getScopeData().tags['turbo_module.method']).toBe('outer'); + + popTurboModuleCall(outer, scope); + expect(getActiveTurboModuleCall()).toBeUndefined(); + }); + + it('handles out-of-order async completion', () => { + const first = pushTurboModuleCall({ name: 'RNSentry', method: 'first', kind: 'async', scope }); + const second = pushTurboModuleCall({ name: 'RNSentry', method: 'second', kind: 'async', scope }); + + // Inner async finishes first — pop the outer one. + popTurboModuleCall(first, scope); + + expect(getTurboModuleCallStack().map(c => c.callId)).toEqual([second]); + expect(scope.getScopeData().tags['turbo_module.method']).toBe('second'); + }); + + it('is a no-op when popping an unknown id', () => { + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'a', kind: 'sync', scope }); + + popTurboModuleCall(9999, scope); + + expect(getActiveTurboModuleCall()?.callId).toBe(id); + }); + + it('assigns monotonically increasing call ids', () => { + const a = pushTurboModuleCall({ name: 'M', method: 'a', kind: 'sync', scope }); + const b = pushTurboModuleCall({ name: 'M', method: 'b', kind: 'sync', scope }); + const c = pushTurboModuleCall({ name: 'M', method: 'c', kind: 'sync', scope }); + + expect(b).toBe(a + 1); + expect(c).toBe(b + 1); + }); +}); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts new file mode 100644 index 0000000000..14869be076 --- /dev/null +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -0,0 +1,129 @@ +import * as SentryCore from '@sentry/core'; +import { Scope } from '@sentry/core'; + +import { _resetTurboModuleTracker, getTurboModuleCallStack } from '../../src/js/turbomodule/turboModuleTracker'; +import { wrapTurboModule } from '../../src/js/turbomodule/wrapTurboModule'; + +describe('wrapTurboModule', () => { + let scope: Scope; + + beforeEach(() => { + _resetTurboModuleTracker(); + scope = new Scope(); + jest.spyOn(SentryCore, 'getCurrentScope').mockReturnValue(scope); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns null/undefined modules unchanged', () => { + expect(wrapTurboModule('X', null)).toBeNull(); + expect(wrapTurboModule('X', undefined)).toBeUndefined(); + }); + + it('tracks sync method calls and pops after completion', () => { + const seenDuringCall: ReturnType = []; + const module = { + doStuff: (a: number, b: number): number => { + seenDuringCall.push(...getTurboModuleCallStack()); + return a + b; + }, + }; + + wrapTurboModule('Mod', module); + + const result = module.doStuff(2, 3); + + expect(result).toBe(5); + expect(seenDuringCall).toHaveLength(1); + expect(seenDuringCall[0]).toMatchObject({ name: 'Mod', method: 'doStuff', kind: 'sync' }); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('pops on synchronous throw', () => { + const module = { + explode: () => { + throw new Error('boom'); + }, + }; + + wrapTurboModule('Mod', module); + + expect(() => module.explode()).toThrow('boom'); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('tracks async method calls until the promise settles', async () => { + let resolveCall: (value: string) => void = () => undefined; + const module = { + asyncOp: () => + new Promise(resolve => { + resolveCall = resolve; + }), + }; + + wrapTurboModule('Mod', module); + + const promise = module.asyncOp(); + expect(getTurboModuleCallStack()).toHaveLength(1); + + resolveCall('done'); + await promise; + + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('pops when an async method rejects', async () => { + const module = { + asyncFail: () => Promise.reject(new Error('nope')), + }; + + wrapTurboModule('Mod', module); + + await expect(module.asyncFail()).rejects.toThrow('nope'); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('skips methods listed in the skip option', () => { + let seen: ReturnType = []; + const module = { + addListener: () => undefined, + doStuff: () => { + seen = getTurboModuleCallStack(); + }, + }; + + wrapTurboModule('Mod', module, { skip: ['addListener'] }); + + module.addListener(); + expect(getTurboModuleCallStack()).toEqual([]); + + module.doStuff(); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ name: 'Mod', method: 'doStuff' }); + }); + + it('does not re-wrap an already wrapped module', () => { + const module = { + doStuff: () => undefined, + }; + wrapTurboModule('Mod', module); + const wrappedOnce = module.doStuff; + wrapTurboModule('Mod', module); + + expect(module.doStuff).toBe(wrappedOnce); + }); + + it('ignores non-function properties', () => { + const module: { version: string; doStuff: () => number } = { + version: '1.0.0', + doStuff: () => 42, + }; + + wrapTurboModule('Mod', module); + + expect(module.version).toBe('1.0.0'); + expect(module.doStuff()).toBe(42); + }); +}); From de4c63d6689a2b944423d9b693c48bc3b1e829bb Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 3 Jun 2026 13:29:30 +0200 Subject: [PATCH 2/3] fixes --- CHANGELOG.md | 1 + .../src/js/turbomodule/turboModuleTracker.ts | 20 ++++ .../src/js/turbomodule/wrapTurboModule.ts | 92 +++++++++++-------- .../turbomodule/turboModuleTracker.test.ts | 27 ++++++ .../test/turbomodule/wrapTurboModule.test.ts | 71 +++++++++++++- 5 files changed, 172 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b27c9e49..9a8ba5286b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Add `disableAutoUpload` option to Expo plugin to disable source map and debug symbol uploads ([#6195](https://github.com/getsentry/sentry-react-native/pull/6195)) - Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192)) - Better route and dynamic param extraction for Expo Router ([#6197](https://github.com/getsentry/sentry-react-native/pull/6197)) +- Attach the active TurboModule method to native crash reports as `contexts.turbo_module` + `turbo_module.name` / `turbo_module.method` tags ([#6227](https://github.com/getsentry/sentry-react-native/pull/6227)) ### Fixes diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts index 295a6af852..d3dc8bd482 100644 --- a/packages/core/src/js/turbomodule/turboModuleTracker.ts +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -93,6 +93,26 @@ export function pushTurboModuleCall(args: { return call.callId; } +/** + * Updates the `kind` of a previously-pushed call (in place) and re-syncs the + * scope if the call is currently the active one. Used by + * {@link wrapTurboModule} once it discovers that a method's return value is + * thenable. + * + * Returns `true` if the call was found and relabelled. + */ +export function relabelTurboModuleCallKind(callId: number, kind: 'sync' | 'async', scope?: Scope): boolean { + const call = stack.find(c => c.callId === callId); + if (!call || call.kind === kind) { + return !!call; + } + call.kind = kind; + if (stack[stack.length - 1] === call) { + syncToScope(call, scope); + } + return true; +} + /** * Records the end of a TurboModule method invocation previously started with * {@link pushTurboModuleCall}. Pops the matching frame off the stack and diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index 216bedad74..1ec4b810f8 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -1,15 +1,16 @@ import { logger } from '@sentry/react'; -import { popTurboModuleCall, pushTurboModuleCall } from './turboModuleTracker'; - -const WRAPPED_FLAG = '__sentryTurboModuleWrapped__'; +import { popTurboModuleCall, pushTurboModuleCall, relabelTurboModuleCallKind } from './turboModuleTracker'; /** - * Marker added to wrapped modules so we never double-wrap (which would push the - * same call twice onto the tracker stack). + * Modules we've already wrapped. Tracked off-module so that even sealed proxies + * (which can't accept a marker property) are protected from double-wrapping. */ -interface MaybeWrapped { - [WRAPPED_FLAG]?: boolean; +let wrappedModules = new WeakSet(); + +/** Tests only. */ +export function _resetWrappedModules(): void { + wrappedModules = new WeakSet(); } /** @@ -18,8 +19,8 @@ interface MaybeWrapped { * `module` reference for chaining convenience. * * - Sync methods are tracked as `kind: 'sync'` and popped right after the call. - * - Async methods (those returning a thenable) are tracked as `kind: 'async'` - * and popped when the returned promise settles. + * - Async methods (those returning a thenable) are relabelled to `kind: 'async'` + * right after the call dispatches and popped when the returned promise settles. * * `skip` can be used to opt specific method names out of tracking (e.g. very * hot, no-op methods like RN's `addListener`/`removeListeners` event-emitter @@ -34,15 +35,24 @@ export function wrapTurboModule( return module; } - const maybeWrapped = module as T & MaybeWrapped; - if (maybeWrapped[WRAPPED_FLAG]) { + if (wrappedModules.has(module)) { return module; } + wrappedModules.add(module); const skip = new Set(options.skip ?? []); + const methodNames = collectMethodNames(module); + + if (methodNames.length === 0) { + logger.warn( + `[TurboModuleTracker] No methods discovered on '${name}' — TurboModule context will not be attached for this module. ` + + `This usually means the module is a JSI HostObject that only materialises methods on first access.`, + ); + return module; + } const target = module as unknown as Record; - for (const key of Object.keys(target)) { + for (const key of methodNames) { if (skip.has(key)) { continue; } @@ -52,9 +62,9 @@ export function wrapTurboModule( } const originalFn = original as (...a: unknown[]) => unknown; - target[key] = function sentryTurboModuleWrapper(this: unknown, ...args: unknown[]): unknown { + const wrapper = function sentryTurboModuleWrapper(this: unknown, ...args: unknown[]): unknown { // We don't know yet whether `original` is sync or async — start optimistic - // as sync, upgrade the scope context if the result is thenable. + // as sync, relabel to 'async' if the result turns out to be thenable. const callId = pushTurboModuleCall({ name, method: key, kind: 'sync' }); let result: unknown; try { @@ -65,13 +75,7 @@ export function wrapTurboModule( } if (isThenable(result)) { - // Re-record as async — clearer in the report. We just overwrite the - // existing tracker frame in place by popping + re-pushing with a fresh - // id would lose ordering, so instead we leave the stack frame alone - // and only relabel for the scope on completion (it's the *active* - // call's `kind` that ends up in `contexts.turbo_module`, and the - // outer perf-logger driven users can push with `kind: 'async'` - // directly when they know up front). + relabelTurboModuleCallKind(callId, 'async'); return (result as Promise).then( value => { popTurboModuleCall(callId); @@ -87,29 +91,41 @@ export function wrapTurboModule( popTurboModuleCall(callId); return result; }; - } - try { - Object.defineProperty(module, WRAPPED_FLAG, { - value: true, - enumerable: false, - configurable: false, - writable: false, - }); - } catch (e) { - // Some TurboModule proxies are sealed — that's fine, we still patched the - // methods, but a second wrap call would be a no-op anyway because the - // properties now point at our wrappers (re-wrapping would still push - // through to `original` which is itself a wrapper, but the per-call - // pushes would double up). Log so this is visible during development. - logger.warn( - `[TurboModuleTracker] Could not mark ${name} as wrapped — repeated wrapping would double-track invocations.`, - ); + try { + target[key] = wrapper; + } catch { + // Sealed / non-writable property — can't intercept this method, but we + // can still wrap the rest. Skip silently; the module-level method-count + // check above is the cliff that catches the "wrapped nothing" case. + } } return module; } +/** + * Returns the union of own + prototype-chain method names on `module`, + * deduplicated and skipping `Object.prototype`. Walking the prototype chain is + * necessary for JSI HostObject-backed TurboModule proxies under RN's New + * Architecture, which can expose methods via the proto chain rather than as + * own enumerable properties. + */ +function collectMethodNames(module: object): string[] { + const seen = new Set(); + let current: object | null = module; + while (current && current !== Object.prototype) { + for (const key of Object.getOwnPropertyNames(current)) { + if (key === 'constructor') { + continue; + } + seen.add(key); + } + current = Object.getPrototypeOf(current) as object | null; + } + return Array.from(seen); +} + function isThenable(value: unknown): value is PromiseLike { if (!value || (typeof value !== 'object' && typeof value !== 'function')) { return false; diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts index e42bb4fab2..6bf0f8b3ac 100644 --- a/packages/core/test/turbomodule/turboModuleTracker.test.ts +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -6,6 +6,7 @@ import { getTurboModuleCallStack, popTurboModuleCall, pushTurboModuleCall, + relabelTurboModuleCallKind, } from '../../src/js/turbomodule/turboModuleTracker'; describe('turboModuleTracker', () => { @@ -90,6 +91,32 @@ describe('turboModuleTracker', () => { expect(getActiveTurboModuleCall()?.callId).toBe(id); }); + it('relabels the active call kind and re-syncs the scope', () => { + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'sync', scope }); + + const relabelled = relabelTurboModuleCallKind(id, 'async', scope); + + expect(relabelled).toBe(true); + expect(getActiveTurboModuleCall()?.kind).toBe('async'); + expect(scope.getScopeData().contexts.turbo_module).toMatchObject({ kind: 'async' }); + }); + + it('relabels a non-top frame without touching the scope context', () => { + const outer = pushTurboModuleCall({ name: 'M', method: 'outer', kind: 'sync', scope }); + pushTurboModuleCall({ name: 'M', method: 'inner', kind: 'sync', scope }); + + relabelTurboModuleCallKind(outer, 'async', scope); + + // outer was relabelled + expect(getTurboModuleCallStack().find(c => c.callId === outer)?.kind).toBe('async'); + // but the active scope context still describes the top of stack ('inner', sync) + expect(scope.getScopeData().contexts.turbo_module).toMatchObject({ method: 'inner', kind: 'sync' }); + }); + + it('relabel returns false for an unknown id', () => { + expect(relabelTurboModuleCallKind(9999, 'async')).toBe(false); + }); + it('assigns monotonically increasing call ids', () => { const a = pushTurboModuleCall({ name: 'M', method: 'a', kind: 'sync', scope }); const b = pushTurboModuleCall({ name: 'M', method: 'b', kind: 'sync', scope }); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index 14869be076..378224c8b7 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -2,13 +2,14 @@ import * as SentryCore from '@sentry/core'; import { Scope } from '@sentry/core'; import { _resetTurboModuleTracker, getTurboModuleCallStack } from '../../src/js/turbomodule/turboModuleTracker'; -import { wrapTurboModule } from '../../src/js/turbomodule/wrapTurboModule'; +import { _resetWrappedModules, wrapTurboModule } from '../../src/js/turbomodule/wrapTurboModule'; describe('wrapTurboModule', () => { let scope: Scope; beforeEach(() => { _resetTurboModuleTracker(); + _resetWrappedModules(); scope = new Scope(); jest.spyOn(SentryCore, 'getCurrentScope').mockReturnValue(scope); }); @@ -74,6 +75,26 @@ describe('wrapTurboModule', () => { expect(getTurboModuleCallStack()).toEqual([]); }); + it('relabels async calls to kind="async" once the call returns a thenable', () => { + let resolveCall: (value: string) => void = () => undefined; + const module = { + asyncOp: () => + new Promise(resolve => { + resolveCall = resolve; + }), + }; + + wrapTurboModule('Mod', module); + + const promise = module.asyncOp(); + + expect(getTurboModuleCallStack()[0]).toMatchObject({ method: 'asyncOp', kind: 'async' }); + expect(scope.getScopeData().contexts.turbo_module).toMatchObject({ method: 'asyncOp', kind: 'async' }); + + resolveCall('done'); + return promise; + }); + it('pops when an async method rejects', async () => { const module = { asyncFail: () => Promise.reject(new Error('nope')), @@ -115,6 +136,54 @@ describe('wrapTurboModule', () => { expect(module.doStuff).toBe(wrappedOnce); }); + it('does not double-wrap a sealed module on repeated calls', () => { + const module: { doStuff: () => void } = { + doStuff: () => undefined, + }; + wrapTurboModule('Mod', module); + Object.seal(module); + + // Repeated wrap attempts must be no-ops, otherwise every real call would + // push the same frame multiple times onto the tracker stack. + wrapTurboModule('Mod', module); + wrapTurboModule('Mod', module); + + module.doStuff(); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('discovers methods exposed via the prototype chain (JSI HostObject shape)', () => { + let stackDuringCall: ReturnType = []; + class HostObjectLike { + public doStuff(): string { + stackDuringCall = getTurboModuleCallStack(); + return 'ok'; + } + } + const module = new HostObjectLike(); + + // sanity: methods are exposed via the prototype, not as own properties + expect(Object.keys(module)).toEqual([]); + + wrapTurboModule('HostObj', module); + + const result = module.doStuff(); + + expect(result).toBe('ok'); + expect(stackDuringCall).toHaveLength(1); + expect(stackDuringCall[0]).toMatchObject({ name: 'HostObj', method: 'doStuff' }); + }); + + it('warns and bails out cleanly when no methods are discoverable', () => { + const warnSpy = jest.spyOn(require('@sentry/react').logger, 'warn').mockImplementation(() => undefined); + + const opaque = Object.create(null) as object; + + wrapTurboModule('Opaque', opaque); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No methods discovered on 'Opaque'")); + }); + it('ignores non-function properties', () => { const module: { version: string; doStuff: () => number } = { version: '1.0.0', From 4f68d95c5e466b25d4179700abc85ec16784d316 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 3 Jun 2026 17:31:18 +0200 Subject: [PATCH 3/3] fix(turbomodule): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses five review comments on the TurboModule crash-time context PR (#6227): - Pin the Scope on each tracker frame at push time. `popTurboModuleCall` and `relabelTurboModuleCallKind` now always operate against the scope captured at push, so an async call that spans a scope switch (`withScope`, isolation-scope swaps) clears the right scope instead of leaking stale `turbo_module` context onto the original. `pop` no longer takes a `scope` argument — it was a footgun. - Replace `setTag(key, undefined)` on clear with an empty-string sentinel. The native `setTag(key: string, value: string)` TurboModule spec requires a string; `undefined` would either be rejected or silently dropped at the bridge. `setContext(key, null)` remains the canonical "no active call" signal; the empty tags exist only to scrub stale `name`/`method` from the previous call. - Defer `wrappedModules.add(module)` until after at least one method has been successfully wrapped. Previously, a JSI HostObject whose methods materialise lazily was permanently locked out after the first (empty) discovery pass — subsequent calls short-circuited even though the module had since gained methods. - Strengthen the sealed-module test to spy on `pushTurboModuleCall` and assert exactly one push per real call. The previous assertion only checked the post-call stack was empty, which is also true under double-wrapping (each wrapper pushes and pops symmetrically). - Move the changelog entry from the already-released `## 8.13.0` section into `## Unreleased`. --- CHANGELOG.md | 2 +- packages/core/etc/sentry-react-native.api.md | 2 +- .../src/js/turbomodule/turboModuleTracker.ts | 68 ++++++++++++------- .../src/js/turbomodule/wrapTurboModule.ts | 15 +++- .../turbomodule/turboModuleTracker.test.ts | 38 ++++++++--- .../test/turbomodule/wrapTurboModule.test.ts | 32 ++++++++- 6 files changed, 118 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0d6be821..3568ad2ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Instrument Expo Router `push`, `replace`, `navigate`, `back`, and `dismiss` (in addition to `prefetch`) with breadcrumbs and spans, and tag the resulting idle navigation span with the initiating `navigation.method` ([#6221](https://github.com/getsentry/sentry-react-native/pull/6221)) - Note: Expo Router span/breadcrumb attributes that may contain user identifiers (`route.href`, `route.params`, and concrete pathnames derived from string hrefs such as `/users/42`) are now gated behind `sendDefaultPii`. When `sendDefaultPii` is off (the default), prefetch spans for string hrefs use `route.name: 'unknown'` and omit `route.href`. Templated object hrefs (e.g. `{ pathname: '/users/[id]' }`) are unaffected. - Warn when Gradle resolves `sentry-android` to a version incompatible with the SDK ([#6238](https://github.com/getsentry/sentry-react-native/pull/6238)) +- Attach the active TurboModule method to native crash reports as `contexts.turbo_module` + `turbo_module.name` / `turbo_module.method` tags ([#6227](https://github.com/getsentry/sentry-react-native/pull/6227)) ### Fixes @@ -44,7 +45,6 @@ - Add `disableAutoUpload` option to Expo plugin to disable source map and debug symbol uploads ([#6195](https://github.com/getsentry/sentry-react-native/pull/6195)) - Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192)) - Better route and dynamic param extraction for Expo Router ([#6197](https://github.com/getsentry/sentry-react-native/pull/6197)) -- Attach the active TurboModule method to native crash reports as `contexts.turbo_module` + `turbo_module.name` / `turbo_module.method` tags ([#6227](https://github.com/getsentry/sentry-react-native/pull/6227)) ### Fixes diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 4402fc0985..510bc4be6b 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -537,7 +537,7 @@ export { OpenAiOptions } export function pauseAppHangTracking(): void; // @public -export function popTurboModuleCall(callId: number, scope?: Scope): void; +export function popTurboModuleCall(callId: number): void; // @public export const primitiveTagIntegration: () => Integration; diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts index d3dc8bd482..06dfc2a060 100644 --- a/packages/core/src/js/turbomodule/turboModuleTracker.ts +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -18,6 +18,16 @@ export interface TurboModuleCall { callId: number; } +interface InternalCall extends TurboModuleCall { + /** + * Scope captured at push time. We pin it so that an async call which spans a + * scope switch (`withScope`, isolation-scope swaps, …) pops the *same* scope + * it pushed onto — otherwise we'd clear `turbo_module` on the wrong scope and + * leave stale data on the original. + */ + scope: Scope; +} + const CONTEXT_KEY = 'turbo_module'; const TAG_NAME = 'turbo_module.name'; const TAG_METHOD = 'turbo_module.method'; @@ -41,7 +51,7 @@ let nextCallId = 0; * `tags` for crash reports) — this covers crashes that happen *after* the * scope update is flushed but is not strictly async-signal-safe. */ -const stack: TurboModuleCall[] = []; +const stack: InternalCall[] = []; /** * Returns the active TurboModule call (top of stack), or `undefined` if no @@ -80,16 +90,17 @@ export function pushTurboModuleCall(args: { kind: 'sync' | 'async'; scope?: Scope; }): number { - const call: TurboModuleCall = { + const call: InternalCall = { name: args.name, method: args.method, kind: args.kind, startedAtMs: Date.now(), callId: nextCallId++, + scope: args.scope ?? getCurrentScope(), }; stack.push(call); - syncToScope(call, args.scope); + syncToScope(call); return call.callId; } @@ -101,14 +112,14 @@ export function pushTurboModuleCall(args: { * * Returns `true` if the call was found and relabelled. */ -export function relabelTurboModuleCallKind(callId: number, kind: 'sync' | 'async', scope?: Scope): boolean { +export function relabelTurboModuleCallKind(callId: number, kind: 'sync' | 'async'): boolean { const call = stack.find(c => c.callId === callId); if (!call || call.kind === kind) { return !!call; } call.kind = kind; if (stack[stack.length - 1] === call) { - syncToScope(call, scope); + syncToScope(call); } return true; } @@ -122,44 +133,55 @@ export function relabelTurboModuleCallKind(callId: number, kind: 'sync' | 'async * `callId` is the value returned by `pushTurboModuleCall`. If the call cannot * be found (e.g. due to a misuse / race), the pop is a no-op. */ -export function popTurboModuleCall(callId: number, scope?: Scope): void { +export function popTurboModuleCall(callId: number): void { // The common case is a perfectly nested LIFO — pop from the end. + let popped: InternalCall | undefined; const top = stack[stack.length - 1]; if (top?.callId === callId) { - stack.pop(); + popped = stack.pop(); } else { // Out-of-order completion (async). Find and splice. const index = stack.findIndex(c => c.callId === callId); if (index < 0) { return; } - stack.splice(index, 1); + [popped] = stack.splice(index, 1); } - const newTop = stack[stack.length - 1]; - if (newTop) { - syncToScope(newTop, scope); - } else { - clearScope(scope); + // Always clear / update on the scope the call was pushed against — the + // current scope may have changed in the meantime (async, withScope, …). + if (popped) { + const newTop = stack[stack.length - 1]; + if (newTop && newTop.scope === popped.scope) { + syncToScope(newTop); + } else { + clearScope(popped.scope); + } } } -function syncToScope(call: TurboModuleCall, scope?: Scope): void { - const target = scope ?? getCurrentScope(); - target.setContext(CONTEXT_KEY, { +function syncToScope(call: InternalCall): void { + call.scope.setContext(CONTEXT_KEY, { name: call.name, method: call.method, kind: call.kind, started_at_ms: call.startedAtMs, call_id: call.callId, }); - target.setTag(TAG_NAME, call.name); - target.setTag(TAG_METHOD, call.method); + call.scope.setTag(TAG_NAME, call.name); + call.scope.setTag(TAG_METHOD, call.method); } -function clearScope(scope?: Scope): void { - const target = scope ?? getCurrentScope(); - target.setContext(CONTEXT_KEY, null); - target.setTag(TAG_NAME, undefined); - target.setTag(TAG_METHOD, undefined); +// Empty-string sentinel for the "no active call" state. We don't pass +// `undefined` because the native `setTag(key, value)` TurboModule spec +// requires a string — the bridge would otherwise see `undefined` and either +// throw or silently drop the call. `setContext(CONTEXT_KEY, null)` is the +// canonical "no active call" signal; the empty tags exist only so the tag set +// doesn't carry stale `name`/`method` from the previous call. +const NO_ACTIVE_CALL = ''; + +function clearScope(scope: Scope): void { + scope.setContext(CONTEXT_KEY, null); + scope.setTag(TAG_NAME, NO_ACTIVE_CALL); + scope.setTag(TAG_METHOD, NO_ACTIVE_CALL); } diff --git a/packages/core/src/js/turbomodule/wrapTurboModule.ts b/packages/core/src/js/turbomodule/wrapTurboModule.ts index 1ec4b810f8..6f32dbcb97 100644 --- a/packages/core/src/js/turbomodule/wrapTurboModule.ts +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -38,12 +38,13 @@ export function wrapTurboModule( if (wrappedModules.has(module)) { return module; } - wrappedModules.add(module); const skip = new Set(options.skip ?? []); const methodNames = collectMethodNames(module); if (methodNames.length === 0) { + // Do NOT add to wrappedModules — a later call (e.g. once a JSI HostObject + // has materialised its methods) should still get a chance to wrap. logger.warn( `[TurboModuleTracker] No methods discovered on '${name}' — TurboModule context will not be attached for this module. ` + `This usually means the module is a JSI HostObject that only materialises methods on first access.`, @@ -51,6 +52,7 @@ export function wrapTurboModule( return module; } + let wrappedAny = false; const target = module as unknown as Record; for (const key of methodNames) { if (skip.has(key)) { @@ -94,13 +96,20 @@ export function wrapTurboModule( try { target[key] = wrapper; + wrappedAny = true; } catch { // Sealed / non-writable property — can't intercept this method, but we - // can still wrap the rest. Skip silently; the module-level method-count - // check above is the cliff that catches the "wrapped nothing" case. + // can still wrap the rest. Skip silently. } } + // Only mark as wrapped if we actually installed at least one wrapper. + // Otherwise a future call (e.g. after the proxy has materialised methods) + // should be allowed to retry. + if (wrappedAny) { + wrappedModules.add(module); + } + return module; } diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts index 6bf0f8b3ac..97dad69df8 100644 --- a/packages/core/test/turbomodule/turboModuleTracker.test.ts +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -49,12 +49,32 @@ describe('turboModuleTracker', () => { it('clears the scope when the stack drains', () => { const id = pushTurboModuleCall({ name: 'RNSentry', method: 'crash', kind: 'sync', scope }); - popTurboModuleCall(id, scope); + popTurboModuleCall(id); expect(getActiveTurboModuleCall()).toBeUndefined(); expect(scope.getScopeData().contexts.turbo_module).toBeUndefined(); - expect(scope.getScopeData().tags['turbo_module.name']).toBeUndefined(); - expect(scope.getScopeData().tags['turbo_module.method']).toBeUndefined(); + // Tags are cleared to the empty-string sentinel (native `setTag` requires a string). + expect(scope.getScopeData().tags['turbo_module.name']).toBe(''); + expect(scope.getScopeData().tags['turbo_module.method']).toBe(''); + }); + + it('pops against the scope captured at push time, not the current scope', () => { + const pushScope = new Scope(); + const otherScope = new Scope(); + + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'async', scope: pushScope }); + expect(pushScope.getScopeData().contexts.turbo_module).toBeDefined(); + + // Simulate a scope switch happening before the async call settles. + // pop must still clear `pushScope`, not `otherScope`. + popTurboModuleCall(id); + + // Scope.setContext(key, null) removes the entry, so contexts.turbo_module is undefined after clear. + expect(pushScope.getScopeData().contexts.turbo_module).toBeUndefined(); + expect(pushScope.getScopeData().tags['turbo_module.name']).toBe(''); + // The unrelated scope was never written to. + expect(otherScope.getScopeData().contexts.turbo_module).toBeUndefined(); + expect(otherScope.getScopeData().tags['turbo_module.name']).toBeUndefined(); }); it('exposes the new top of stack after popping a nested call', () => { @@ -63,12 +83,12 @@ describe('turboModuleTracker', () => { expect(scope.getScopeData().tags['turbo_module.method']).toBe('inner'); - popTurboModuleCall(inner, scope); + popTurboModuleCall(inner); expect(getActiveTurboModuleCall()?.callId).toBe(outer); expect(scope.getScopeData().tags['turbo_module.method']).toBe('outer'); - popTurboModuleCall(outer, scope); + popTurboModuleCall(outer); expect(getActiveTurboModuleCall()).toBeUndefined(); }); @@ -77,7 +97,7 @@ describe('turboModuleTracker', () => { const second = pushTurboModuleCall({ name: 'RNSentry', method: 'second', kind: 'async', scope }); // Inner async finishes first — pop the outer one. - popTurboModuleCall(first, scope); + popTurboModuleCall(first); expect(getTurboModuleCallStack().map(c => c.callId)).toEqual([second]); expect(scope.getScopeData().tags['turbo_module.method']).toBe('second'); @@ -86,7 +106,7 @@ describe('turboModuleTracker', () => { it('is a no-op when popping an unknown id', () => { const id = pushTurboModuleCall({ name: 'RNSentry', method: 'a', kind: 'sync', scope }); - popTurboModuleCall(9999, scope); + popTurboModuleCall(9999); expect(getActiveTurboModuleCall()?.callId).toBe(id); }); @@ -94,7 +114,7 @@ describe('turboModuleTracker', () => { it('relabels the active call kind and re-syncs the scope', () => { const id = pushTurboModuleCall({ name: 'RNSentry', method: 'captureEnvelope', kind: 'sync', scope }); - const relabelled = relabelTurboModuleCallKind(id, 'async', scope); + const relabelled = relabelTurboModuleCallKind(id, 'async'); expect(relabelled).toBe(true); expect(getActiveTurboModuleCall()?.kind).toBe('async'); @@ -105,7 +125,7 @@ describe('turboModuleTracker', () => { const outer = pushTurboModuleCall({ name: 'M', method: 'outer', kind: 'sync', scope }); pushTurboModuleCall({ name: 'M', method: 'inner', kind: 'sync', scope }); - relabelTurboModuleCallKind(outer, 'async', scope); + relabelTurboModuleCallKind(outer, 'async'); // outer was relabelled expect(getTurboModuleCallStack().find(c => c.callId === outer)?.kind).toBe('async'); diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index 378224c8b7..6dbabaada9 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -1,6 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Scope } from '@sentry/core'; +import * as tracker from '../../src/js/turbomodule/turboModuleTracker'; import { _resetTurboModuleTracker, getTurboModuleCallStack } from '../../src/js/turbomodule/turboModuleTracker'; import { _resetWrappedModules, wrapTurboModule } from '../../src/js/turbomodule/wrapTurboModule'; @@ -143,15 +144,42 @@ describe('wrapTurboModule', () => { wrapTurboModule('Mod', module); Object.seal(module); - // Repeated wrap attempts must be no-ops, otherwise every real call would - // push the same frame multiple times onto the tracker stack. + // Repeated wrap attempts must be no-ops — every real call should push + // exactly one frame onto the tracker, no matter how many times we wrapped. wrapTurboModule('Mod', module); wrapTurboModule('Mod', module); + const pushSpy = jest.spyOn(tracker, 'pushTurboModuleCall'); module.doStuff(); + expect(pushSpy).toHaveBeenCalledTimes(1); expect(getTurboModuleCallStack()).toEqual([]); }); + it('retries wrapping a previously-empty module (lazy JSI HostObject)', () => { + const warnSpy = jest.spyOn(require('@sentry/react').logger, 'warn').mockImplementation(() => undefined); + + // First call: methods not yet materialised — should warn, NOT mark as wrapped. + const lazyModule: { doStuff?: () => string } = Object.create(null) as { doStuff?: () => string }; + wrapTurboModule('Lazy', lazyModule); + expect(warnSpy).toHaveBeenCalled(); + + // Methods materialise (simulating a HostObject's lazy method resolution). + let seenDuringCall: ReturnType = []; + lazyModule.doStuff = () => { + seenDuringCall = getTurboModuleCallStack(); + return 'ok'; + }; + + // Second wrap must now actually install a wrapper on the freshly-discovered method. + wrapTurboModule('Lazy', lazyModule); + + const result = lazyModule.doStuff(); + + expect(result).toBe('ok'); + expect(seenDuringCall).toHaveLength(1); + expect(seenDuringCall[0]).toMatchObject({ name: 'Lazy', method: 'doStuff' }); + }); + it('discovers methods exposed via the prototype chain (JSI HostObject shape)', () => { let stackDuringCall: ReturnType = []; class HostObjectLike {