From f43a4f573156917e702de772eb64eae005372cf0 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 28 May 2026 11:16:12 +0200 Subject: [PATCH] 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); + }); +});