Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
//
// @public
export const appStartIntegration: (input?: {
standalone?: boolean;
standalone?: boolean | undefined;
}) => AppStartIntegration;

export { Breadcrumb }
Expand Down Expand Up @@ -334,7 +334,7 @@
export const feedbackIntegration: (initOptions?: Partial<FeedbackFormProps> & {
buttonOptions?: FeedbackButtonProps;
screenshotButtonOptions?: ScreenshotButtonProps;
colorScheme?: "system" | "light" | "dark";
colorScheme?: 'system' | 'light' | 'dark';
themeLight?: Partial<FeedbackFormTheme>;
themeDark?: Partial<FeedbackFormTheme>;
enableShakeToReport?: boolean;
Expand All @@ -346,7 +346,10 @@
export { functionToStringIntegration }

export { getActiveSpan }

// @public
export function getActiveTurboModuleCall(): TurboModuleCall | undefined;

Check warning on line 351 in packages/core/etc/sentry-react-native.api.md

View check run for this annotation

@sentry/warden / warden: find-bugs

[PCL-LYA] Async TurboModule calls always reported as `kind: 'sync'` in crash context (additional location)

The JSDoc for `wrapTurboModule` promises async calls are tracked as `kind: 'async'`, and the inline comment says the frame is "relabeled for the scope on completion", but the `.then()` callbacks only call `popTurboModuleCall` — the `kind` field is never updated from `'sync'`. Every TurboModule call in crash reports will show `kind: 'sync'`.

export { getClient }

// Warning: (ae-forgotten-export) The symbol "ReactNativeTracingIntegration" needs to be exported by the entry point index.d.ts
Expand All @@ -371,6 +374,9 @@

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
Expand Down Expand Up @@ -498,11 +504,22 @@
// @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
Expand Down Expand Up @@ -633,14 +650,15 @@

// @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<typeof defaultIdleOptions> & {
isAppRestart?: boolean;
export const startIdleNavigationSpan: (startSpanOption: StartSpanOptions, input?: Partial<{
idleTimeout: number;
finalTimeout: number;
}> & {
isAppRestart?: boolean | undefined;
}) => Span | undefined;

// @public
Expand Down Expand Up @@ -712,6 +730,27 @@

export { TransactionEvent }

// @public
export interface TurboModuleCall {

Check warning on line 734 in packages/core/etc/sentry-react-native.api.md

View check run for this annotation

@sentry/warden / warden: code-review

[EUK-DJP] Async TurboModule calls always recorded as `kind: 'sync'` in crash context (additional location)

Every method call pushes with `kind: 'sync'` (line 58), and when the result is thenable there is no mutation or re-push to change the stack frame's `kind` to `'async'` — so async calls appear as `kind: 'sync'` in `contexts.turbo_module` for the entire duration of the async operation.
callId: number;
kind: 'sync' | 'async';
method: string;
name: string;
startedAtMs: number;
}

// @public
export const turboModuleContextIntegration: (options?: TurboModuleContextOptions) => Integration;

// @public (undocumented)
export interface TurboModuleContextOptions {
modules?: Array<{
name: string;
module: object | null | undefined;
skipMethods?: ReadonlyArray<string>;
}>;
}

// @public (undocumented)
export const Unmask: HostComponent<ViewProps> | React_2.ComponentType<ViewProps>;

Expand Down Expand Up @@ -756,6 +795,11 @@
// @public
export function wrapExpoRouter<T extends ExpoRouter>(router: T): T;

// @public
export function wrapTurboModule<T extends object>(name: string, module: T | null | undefined, options?: {

Check warning on line 799 in packages/core/etc/sentry-react-native.api.md

View check run for this annotation

@sentry/warden / warden: code-review

[AF3-FXS] Sealed TurboModule proxies can be double-wrapped, causing duplicate tracker pushes per call (additional location)

When `Object.defineProperty` throws for a sealed proxy, `WRAPPED_FLAG` is never set, so the guard at line 39 (`if (maybeWrapped[WRAPPED_FLAG])`) always passes on subsequent calls to `wrapTurboModule`, re-wrapping already-wrapped methods and pushing the same call twice onto the tracker stack.
skip?: ReadonlyArray<string>;
}): 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
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 6 additions & 0 deletions packages/core/src/js/integrations/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
spotlightIntegration,
stallTrackingIntegration,
timeToDisplayIntegration,
turboModuleContextIntegration,
userInteractionIntegration,

Check warning on line 45 in packages/core/src/js/integrations/default.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[B3Z-ZZF] `Object.keys` in `wrapTurboModule` silently skips prototype methods on JSI TurboModule proxies (additional location)

`wrapTurboModule` uses `Object.keys(target)` which only enumerates own enumerable properties; if a real JSI-backed TurboModule proxy exposes methods via its prototype chain (as some RN HostObject implementations do), nothing gets wrapped and the integration silently provides no crash context.
viewHierarchyIntegration,
} from './exports';

Expand Down Expand Up @@ -172,5 +173,10 @@

integrations.push(primitiveTagIntegration());

if (options.enableNative) {
// Attribute native crashes to the active TurboModule method (see #6163).
integrations.push(turboModuleContextIntegration());
}

return integrations;
}
2 changes: 2 additions & 0 deletions packages/core/src/js/integrations/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/js/integrations/turboModuleContext.ts
Original file line number Diff line number Diff line change
@@ -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<string> }>;
}

// `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 });

Check warning on line 43 in packages/core/src/js/integrations/turboModuleContext.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[PCL-LYA] Async TurboModule calls always reported as `kind: 'sync'` in crash context (additional location)

The JSDoc for `wrapTurboModule` promises async calls are tracked as `kind: 'async'`, and the inline comment says the frame is "relabeled for the scope on completion", but the `.then()` callbacks only call `popTurboModuleCall` — the `kind` field is never updated from `'sync'`. Every TurboModule call in crash reports will show `kind: 'sync'`.

for (const entry of options.modules ?? []) {
wrapTurboModule(entry.name, entry.module, { skip: entry.skipMethods });
}
},
};
};
8 changes: 8 additions & 0 deletions packages/core/src/js/turbomodule/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
getActiveTurboModuleCall,
getTurboModuleCallStack,
popTurboModuleCall,
pushTurboModuleCall,
} from './turboModuleTracker';
export type { TurboModuleCall } from './turboModuleTracker';
export { wrapTurboModule } from './wrapTurboModule';

Check warning on line 8 in packages/core/src/js/turbomodule/index.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[HF7-EU3] Double-tracking when `defineProperty` fails on a sealed proxy (additional location)

When `Object.defineProperty` throws (e.g. on a sealed proxy), `WRAPPED_FLAG` is never stamped on the module. A subsequent call to `wrapTurboModule` with the same module will bypass the `if (maybeWrapped[WRAPPED_FLAG])` guard, re-wrap every method a second time, and push **two** tracker frames per invocation instead of one — corrupting the call stack and scope context. The comment in the catch block describes this exact outcome but incorrectly labels it "a no-op".
145 changes: 145 additions & 0 deletions packages/core/src/js/turbomodule/turboModuleTracker.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading