diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d512940d6..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 diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 7f63a2d1e3..510bc4be6b 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 } @@ -335,7 +335,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; @@ -354,6 +354,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 @@ -378,6 +381,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 @@ -530,11 +536,22 @@ export { OpenAiOptions } // @public export function pauseAppHangTracking(): void; +// @public +export function popTurboModuleCall(callId: number): 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 @@ -729,14 +746,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 @@ -808,6 +826,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; @@ -852,6 +891,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 17feb632cb..1adbd21b07 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -157,3 +157,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 b03ff102e6..4c4fb963b3 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'; @@ -174,5 +175,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..06dfc2a060 --- /dev/null +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -0,0 +1,187 @@ +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; +} + +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'; + +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: InternalCall[] = []; + +/** + * 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: InternalCall = { + name: args.name, + method: args.method, + kind: args.kind, + startedAtMs: Date.now(), + callId: nextCallId++, + scope: args.scope ?? getCurrentScope(), + }; + + stack.push(call); + syncToScope(call); + 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'): 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); + } + return true; +} + +/** + * 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): 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) { + popped = stack.pop(); + } else { + // Out-of-order completion (async). Find and splice. + const index = stack.findIndex(c => c.callId === callId); + if (index < 0) { + return; + } + [popped] = stack.splice(index, 1); + } + + // 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: 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, + }); + call.scope.setTag(TAG_NAME, call.name); + call.scope.setTag(TAG_METHOD, call.method); +} + +// 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 new file mode 100644 index 0000000000..6f32dbcb97 --- /dev/null +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -0,0 +1,144 @@ +import { logger } from '@sentry/react'; + +import { popTurboModuleCall, pushTurboModuleCall, relabelTurboModuleCallKind } from './turboModuleTracker'; + +/** + * 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. + */ +let wrappedModules = new WeakSet(); + +/** Tests only. */ +export function _resetWrappedModules(): void { + wrappedModules = new WeakSet(); +} + +/** + * 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 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 + * 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; + } + + if (wrappedModules.has(module)) { + return 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.`, + ); + return module; + } + + let wrappedAny = false; + const target = module as unknown as Record; + for (const key of methodNames) { + if (skip.has(key)) { + continue; + } + const original = target[key]; + if (typeof original !== 'function') { + continue; + } + const originalFn = original as (...a: 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, relabel to 'async' if the result turns out to be 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)) { + relabelTurboModuleCallKind(callId, 'async'); + return (result as Promise).then( + value => { + popTurboModuleCall(callId); + return value; + }, + err => { + popTurboModuleCall(callId); + throw err; + }, + ); + } + + popTurboModuleCall(callId); + return result; + }; + + 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. + } + } + + // 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; +} + +/** + * 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; + } + 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..97dad69df8 --- /dev/null +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -0,0 +1,148 @@ +import { Scope } from '@sentry/core'; + +import { + _resetTurboModuleTracker, + getActiveTurboModuleCall, + getTurboModuleCallStack, + popTurboModuleCall, + pushTurboModuleCall, + relabelTurboModuleCallKind, +} 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); + + expect(getActiveTurboModuleCall()).toBeUndefined(); + expect(scope.getScopeData().contexts.turbo_module).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', () => { + 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); + + expect(getActiveTurboModuleCall()?.callId).toBe(outer); + expect(scope.getScopeData().tags['turbo_module.method']).toBe('outer'); + + popTurboModuleCall(outer); + 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); + + 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); + + 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'); + + 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'); + + // 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 }); + 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..6dbabaada9 --- /dev/null +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -0,0 +1,226 @@ +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'; + +describe('wrapTurboModule', () => { + let scope: Scope; + + beforeEach(() => { + _resetTurboModuleTracker(); + _resetWrappedModules(); + 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('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')), + }; + + 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('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 — 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 { + 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', + doStuff: () => 42, + }; + + wrapTurboModule('Mod', module); + + expect(module.version).toBe('1.0.0'); + expect(module.doStuff()).toBe(42); + }); +});