diff --git a/CHANGELOG.md b/CHANGELOG.md index f7b3e9efd0..89bdb8132e 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..a4b02e65c0 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -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 @@ -808,6 +825,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 +890,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..a577da3685 --- /dev/null +++ b/packages/core/src/js/integrations/turboModuleContext.ts @@ -0,0 +1,71 @@ +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 }>; +} + +// Methods on RNSentry that must NOT be tracked: +// +// - `addListener` / `removeListeners` are RN event-emitter stubs that fire on +// every subscriber registration — tracking them would just churn the scope. +// +// - The scope-sync methods (`setContext`, `setTag`, `setExtra`, `setUser`, +// `addBreadcrumb`, `clearBreadcrumbs`, `setAttribute`, `setAttributes`, +// `removeAttribute`) are called by our own `enableSyncToNative` hook every +// time anything writes to a JS Scope. Tracking them would cause infinite +// recursion: `pushTurboModuleCall` -> `scope.setContext` -> `NATIVE.setContext` +// -> `RNSentry.setContext` (wrapped) -> `pushTurboModuleCall` -> ... . +const RNSENTRY_SKIP = [ + 'addListener', + 'removeListeners', + 'setContext', + 'setTag', + 'setExtra', + 'setUser', + 'addBreadcrumb', + 'clearBreadcrumbs', + 'setAttribute', + 'setAttributes', + 'removeAttribute', +] 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..b0436a07d1 --- /dev/null +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -0,0 +1,226 @@ +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(), + }; + + // Atomic push: if `syncToScope` throws (e.g. a scope-sync hook calls into a + // native bridge that rejects with `_NativeClientError`), roll back the stack + // push so we don't leak a frame. + stack.push(call); + try { + syncToScope(call); + } catch (e) { + stack.pop(); + throw e; + } + 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); + } + + if (!popped) { + return; + } + + // 1. Reflect the new state of `popped.scope` (the scope this call was pinned + // to). When scopes interleave on the stack (e.g. [A@s1, B@s2, C@s1] and + // we pop C), the immediate stack top is *not* the right thing to look at: + // there may still be a deeper frame holding `popped.scope` whose context + // we'd wipe by calling `clearScope`. Walk the stack from the top down and + // re-sync onto the newest remaining frame on `popped.scope`; only clear + // if none is left. + let remainingOnSameScope: InternalCall | undefined; + for (let i = stack.length - 1; i >= 0; i--) { + const frame = stack[i]; + if (frame && frame.scope === popped.scope) { + remainingOnSameScope = frame; + break; + } + } + if (remainingOnSameScope) { + syncToScope(remainingOnSameScope); + } else { + clearScope(popped.scope); + } + + // 2. The native SDKs (sentry-cocoa / sentry-java) share a *single* native + // scope, but JS has many Scope objects (global, isolation, withScope, …). + // Every `Scope#setContext` / `Scope#setTag` we just made in step 1 fired + // the `scopeSync.ts` hook and overwrote the native scope's `turbo_module` + // context — even if the global top of the stack still lives on a + // *different* JS scope. Without this second sync, a crash that follows a + // cross-scope pop would land in native without the active TurboModule + // attribution, even though the global stack still has an in-flight call. + // + // Re-sync the global stack top via *its* scope so native ends up holding + // the correct active-call context. The intermediate native write in step 1 + // is wasted but unavoidable without bypassing the public Scope API. + const globalTop = stack[stack.length - 1]; + if (globalTop && globalTop !== remainingOnSameScope) { + syncToScope(globalTop); + } +} + +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..48741e00cb --- /dev/null +++ b/packages/core/src/js/turbomodule/wrapTurboModule.ts @@ -0,0 +1,200 @@ +import { logger } from '@sentry/core'; + +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; + } + // `target[key]` may be a getter (some JSI HostObject proxies expose methods + // as accessors under the New Architecture). Guard the read so a throwing + // getter is treated like a non-wrappable property instead of aborting the + // entire wrap loop. + let original: unknown; + try { + original = target[key]; + } catch { + continue; + } + if (typeof original !== 'function') { + continue; + } + const originalFn = original as (...a: unknown[]) => unknown; + + const wrapper = function sentryTurboModuleWrapper(this: unknown, ...args: unknown[]): unknown { + // The instrumentation must never break the user's call — every tracker + // interaction is isolated so a failure inside Sentry only drops the + // attribution data, never the real TurboModule invocation. + // + // 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. + let callId: number | undefined; + try { + callId = pushTurboModuleCall({ name, method: key, kind: 'sync' }); + } catch (e) { + logger.warn(`[TurboModuleTracker] push failed for ${name}.${key}: ${String(e)}`); + } + + let result: unknown; + try { + result = originalFn.apply(this, args); + } catch (e) { + safePop(callId, name, key); + throw e; + } + + if (isThenable(result)) { + safeRelabel(callId, 'async', name, key); + return (result as Promise).then( + value => { + safePop(callId, name, key); + return value; + }, + err => { + safePop(callId, name, key); + throw err; + }, + ); + } + + safePop(callId, name, key); + 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); + } else { + // We discovered methods but couldn't intercept any of them — e.g. the + // module is frozen, or every method is a read-only accessor. Surface this + // so the silent no-op is debuggable (the issue would otherwise look like + // "no crash context attached" with no obvious cause). + logger.warn( + `[TurboModuleTracker] '${name}' has methods but none could be wrapped — TurboModule context will not be attached. ` + + `This usually means the module is frozen or its methods are non-writable accessors.`, + ); + } + + 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 safePop(callId: number | undefined, name: string, method: string): void { + if (callId === undefined) { + return; + } + try { + popTurboModuleCall(callId); + } catch (e) { + logger.warn(`[TurboModuleTracker] pop failed for ${name}.${method}: ${String(e)}`); + } +} + +function safeRelabel(callId: number | undefined, kind: 'sync' | 'async', name: string, method: string): void { + if (callId === undefined) { + return; + } + try { + relabelTurboModuleCallKind(callId, kind); + } catch (e) { + logger.warn(`[TurboModuleTracker] relabel failed for ${name}.${method}: ${String(e)}`); + } +} + +function isThenable(value: unknown): value is PromiseLike { + if (!value || (typeof value !== 'object' && typeof value !== 'function')) { + return false; + } + // A user-defined `then` getter could throw — don't let that escape into the + // wrapper (which would leak the in-flight tracker frame on top of the bug). + try { + const then = (value as { then?: unknown }).then; + return typeof then === 'function'; + } catch { + return false; + } +} diff --git a/packages/core/test/integrations/turboModuleContext.test.ts b/packages/core/test/integrations/turboModuleContext.test.ts new file mode 100644 index 0000000000..d0b6207159 --- /dev/null +++ b/packages/core/test/integrations/turboModuleContext.test.ts @@ -0,0 +1,109 @@ +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', + 'setContext', + 'setTag', + 'setExtra', + 'setUser', + 'addBreadcrumb', + 'clearBreadcrumbs', + 'setAttribute', + 'setAttributes', + 'removeAttribute', + ], + }); + }); + + it('does not wrap scope-sync methods on RNSentry (would recurse infinitely)', () => { + // Sanity check: every method `scopeSync.ts` forwards to NATIVE.* via + // RNSentry must be in the skip list, otherwise scope writes recurse. + const fakeModule = { + setContext: jest.fn(), + setTag: jest.fn(), + setExtra: jest.fn(), + setUser: jest.fn(), + addBreadcrumb: jest.fn(), + clearBreadcrumbs: jest.fn(), + setAttribute: jest.fn(), + setAttributes: jest.fn(), + removeAttribute: jest.fn(), + crash: jest.fn(), + }; + jest.spyOn(wrapper, 'getRNSentryModule').mockReturnValue(fakeModule as never); + + const originalCrash = fakeModule.crash; + turboModuleContextIntegration().setupOnce!(); + + // crash is wrapped (replaced with sentryTurboModuleWrapper, which is a plain + // function and therefore lacks the `_isMockFunction` marker the jest mocks carry). + expect(fakeModule.crash).not.toBe(originalCrash); + expect((fakeModule.crash as { _isMockFunction?: boolean })._isMockFunction).toBeUndefined(); + for (const method of [ + 'setContext', + 'setTag', + 'setExtra', + 'setUser', + 'addBreadcrumb', + 'clearBreadcrumbs', + 'setAttribute', + 'setAttributes', + 'removeAttribute', + ] as const) { + // jest mocks expose `_isMockFunction` — if the method is still the + // original mock, it's intact; if it were our wrapper, that property + // would be missing. + expect(fakeModule[method]._isMockFunction).toBe(true); + } + }); + + 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..ceb6e381f1 --- /dev/null +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -0,0 +1,244 @@ +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('re-syncs the global stack top to its scope after a cross-scope pop', () => { + // Native SDKs share one scope; when scope A is cleared on pop, the + // scopeSync hook would also wipe native. We must re-sync the global top + // (which lives on scope B) so native crash reports keep the active call. + const scopeA = new Scope(); + const scopeB = new Scope(); + + const aCallId = pushTurboModuleCall({ name: 'A', method: 'a', kind: 'sync', scope: scopeA }); + pushTurboModuleCall({ name: 'B', method: 'b', kind: 'sync', scope: scopeB }); + + const setContextSpy = jest.spyOn(scopeB, 'setContext'); + + popTurboModuleCall(aCallId); + + // scopeA's frame is gone, so scopeA's context was cleared (good for JS hygiene). + expect(scopeA.getScopeData().contexts.turbo_module).toBeUndefined(); + // scopeB still holds the active call — and we proactively re-synced it via + // scopeB.setContext so the shared native scope ends up holding B's data + // rather than the null left behind by scopeA's clear. + expect(scopeB.getScopeData().contexts.turbo_module).toMatchObject({ name: 'B', method: 'b' }); + expect(setContextSpy).toHaveBeenCalledWith('turbo_module', expect.objectContaining({ name: 'B', method: 'b' })); + }); + + it('does not redundantly re-sync the global top when popping leaves it on the same scope', () => { + const outerId = pushTurboModuleCall({ name: 'M', method: 'outer', kind: 'sync', scope }); + const innerId = pushTurboModuleCall({ name: 'M', method: 'inner', kind: 'sync', scope }); + + const setContextSpy = jest.spyOn(scope, 'setContext'); + popTurboModuleCall(innerId); + + // Exactly one setContext call to restore the 'outer' frame on `scope`. + // (Without dedup, the global-top re-sync would fire a second redundant write.) + expect(setContextSpy).toHaveBeenCalledTimes(1); + expect(setContextSpy).toHaveBeenCalledWith('turbo_module', expect.objectContaining({ method: 'outer' })); + + popTurboModuleCall(outerId); + }); + + 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('preserves context on a scope that still has a deeper active call after interleaved pop', () => { + const scopeA = new Scope(); + const scopeB = new Scope(); + + // Stack: [A@scopeA, B@scopeB, C@scopeA]. Pop C — scopeA must keep its + // turbo_module context (because A is still active), and re-sync to A. + const a = pushTurboModuleCall({ name: 'M', method: 'a', kind: 'sync', scope: scopeA }); + pushTurboModuleCall({ name: 'M', method: 'b', kind: 'sync', scope: scopeB }); + const c = pushTurboModuleCall({ name: 'M', method: 'c', kind: 'sync', scope: scopeA }); + + expect(scopeA.getScopeData().tags['turbo_module.method']).toBe('c'); + + popTurboModuleCall(c); + + // scopeA still has an active frame (A) — must NOT be cleared, and must be + // re-synced to describe A, not C. + expect(scopeA.getScopeData().contexts.turbo_module).toMatchObject({ + name: 'M', + method: 'a', + call_id: a, + }); + expect(scopeA.getScopeData().tags['turbo_module.method']).toBe('a'); + + // scopeB still has its own active frame and is untouched. + expect(scopeB.getScopeData().tags['turbo_module.method']).toBe('b'); + }); + + it('clears a scope only when no frames remain on it after an interleaved pop', () => { + const scopeA = new Scope(); + const scopeB = new Scope(); + + const a = pushTurboModuleCall({ name: 'M', method: 'a', kind: 'sync', scope: scopeA }); + const b = pushTurboModuleCall({ name: 'M', method: 'b', kind: 'sync', scope: scopeB }); + + // Pop the bottom frame (A). scopeA should clear; scopeB untouched. + popTurboModuleCall(a); + + expect(scopeA.getScopeData().contexts.turbo_module).toBeUndefined(); + expect(scopeA.getScopeData().tags['turbo_module.name']).toBe(''); + expect(scopeB.getScopeData().tags['turbo_module.method']).toBe('b'); + + popTurboModuleCall(b); + }); + + 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('rolls back the stack push if syncToScope throws (atomic push)', () => { + const failingScope = new Scope(); + jest.spyOn(failingScope, 'setContext').mockImplementation(() => { + throw new Error('native bridge boom'); + }); + + const before = getTurboModuleCallStack().length; + expect(() => pushTurboModuleCall({ name: 'X', method: 'y', kind: 'sync', scope: failingScope })).toThrow( + 'native bridge boom', + ); + // Stack is unchanged — the failed push didn't leak a frame. + expect(getTurboModuleCallStack().length).toBe(before); + }); + + 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..37a4e6068a --- /dev/null +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -0,0 +1,325 @@ +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/core').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 when methods are discovered but none could be wrapped (frozen module)', () => { + const warnSpy = jest.spyOn(require('@sentry/core').logger, 'warn').mockImplementation(() => undefined); + + const frozen = Object.freeze({ doStuff: () => 'ok' }); + + wrapTurboModule('Frozen', frozen); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("'Frozen' has methods but none could be wrapped")); + }); + + it('still calls the original method when the tracker push throws (native bridge error)', () => { + const warnSpy = jest.spyOn(require('@sentry/core').logger, 'warn').mockImplementation(() => undefined); + // Simulate a scope-sync hook that calls into a native bridge which throws. + jest.spyOn(scope, 'setContext').mockImplementation(() => { + throw new Error('NATIVE.setContext boom'); + }); + + const originalFn = jest.fn(() => 'real-result'); + const module = { doStuff: originalFn }; + + wrapTurboModule('Mod', module); + + // The user's call must still complete with the original return value + // — Sentry's instrumentation can never break the wrapped TurboModule. + const result = module.doStuff(); + + expect(result).toBe('real-result'); + expect(originalFn).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('push failed for Mod.doStuff')); + // And the failed push left no leaked frame on the tracker. + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('still calls the original method when the tracker pop throws', () => { + const warnSpy = jest.spyOn(require('@sentry/core').logger, 'warn').mockImplementation(() => undefined); + + const originalFn = jest.fn(() => 42); + const module = { doStuff: originalFn }; + + wrapTurboModule('Mod', module); + + // Make `setContext` throw only on the pop's restore/clear path. We do this + // by letting push succeed, then breaking setContext before the pop fires. + let setContextCalls = 0; + jest.spyOn(scope, 'setContext').mockImplementation(() => { + setContextCalls++; + if (setContextCalls > 1) { + throw new Error('clear boom'); + } + return scope; + }); + + expect(() => module.doStuff()).not.toThrow(); + expect(originalFn).toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('pop failed for Mod.doStuff')); + }); + + it('does not leak a tracker frame when the result has a throwing `then` getter', () => { + const trap = Object.defineProperty({}, 'then', { + get() { + throw new Error('boom'); + }, + }); + const module = { + weird: () => trap, + }; + + wrapTurboModule('Mod', module); + + // Must not throw, must not leave a frame on the stack. + expect(() => module.weird()).not.toThrow(); + expect(getTurboModuleCallStack()).toEqual([]); + }); + + it('warns and bails out cleanly when no methods are discoverable', () => { + const warnSpy = jest.spyOn(require('@sentry/core').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('skips properties whose getter throws (JSI HostObject accessor edge case)', () => { + let stackDuringCall: ReturnType = []; + const module = { + doStuff: () => { + stackDuringCall = getTurboModuleCallStack(); + return 'ok'; + }, + }; + Object.defineProperty(module, 'throwingGetter', { + enumerable: true, + configurable: true, + get() { + throw new Error('getter boom'); + }, + }); + + // Wrapping must not propagate the getter's throw; wrappable methods on the + // same module should still be wrapped. + expect(() => wrapTurboModule('Mod', module)).not.toThrow(); + + expect(module.doStuff()).toBe('ok'); + expect(stackDuringCall).toHaveLength(1); + expect(stackDuringCall[0]).toMatchObject({ name: 'Mod', method: 'doStuff' }); + }); + + 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); + }); +});