From 3b618c5977974bee2e6cec7028a891f7a6d954f8 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 17 Jun 2026 14:37:41 +0200 Subject: [PATCH 01/22] feat(core): Wire TurboModulePerfLogger on iOS and Android Install a Sentry-owned `facebook::react::NativeModulePerfLogger` on both platforms so the SDK observes every TurboModule lifecycle event \u2014 `moduleDataCreate*`, `moduleCreate*`, sync/async method call `start`/`end`/`fail`, async dispatch and execution `start`/`end`/`fail` \u2014 for follow-up features (crash attribution, per-module spans, aggregated stats) to plug into. The implementation is split into: - **Shared C++** (`packages/core/cpp/`): a single `SentryTurboModulePerfController` singleton owns the installed logger and an atomic `enabled` flag. When disabled, every callback hits one atomic load and returns. When enabled, callbacks are forwarded to a swappable `ISentryTurboModulePerfSink` \u2014 follow-up issues ship the sinks; this PR just exposes the hook. - **iOS**: the perf logger is installed from a dedicated installer class's `+load` so it fires before `RCTBridge` / `RCTHost` create their first TurboModule. (`RNSentry`'s own `+load` is reserved by `RCT_EXPORT_MODULE()`.) The cpp/ directory is added to the podspec sources; files are guarded with `RCT_NEW_ARCH_ENABLED` so Old Arch builds compile to empty TUs. - **Android**: a new `libsentry-tm-perf-logger.so` shared library is built via CMake under New Architecture only and exposes `JNI_OnLoad` + a tiny `nativeSetEnabled` JNI hook. It links against React Native's `reactnative` prefab; the missing `` header is plugged by pointing the include path at the source tree (mirroring how react-native-reanimated resolves react-native via the standard `REACT_NATIVE_NODE_MODULES_DIR` / `require.resolve` fallback). `RNSentryPackage`'s static initializer `System.loadLibrary`s the perf-logger lib \u2014 host apps do NOT need to touch their own `OnLoad.cpp`. A guarded `try { \u2026 } catch (UnsatisfiedLinkError)` keeps Old Architecture (and any host that strips the lib) working as before. Runtime gate: new `enableTurboModuleTracking` option on `Sentry.init`, default `false` for this first release so the foundation lands without behavioral change. The native logger is always installed (we never want to miss early lifecycle events), the flag only decides whether forwarded callbacks reach the Sentry sink. The option is plumbed through `initNativeSdk` on both platforms. Foundation only \u2014 no sink is installed in this PR. Follow-up issues ship the actual instrumentation. Closes #6162 --- CHANGELOG.md | 1 + packages/core/RNSentry.podspec | 6 +- packages/core/android/CMakeLists.txt | 62 ++++++ packages/core/android/build.gradle | 47 +++++ .../io/sentry/react/RNSentryModuleImpl.java | 8 + .../java/io/sentry/react/RNSentryPackage.java | 22 ++ .../react/RNSentryTurboModulePerfTracker.java | 51 +++++ packages/core/android/src/main/jni/OnLoad.cpp | 37 ++++ .../core/cpp/SentryTurboModulePerfLogger.cpp | 198 ++++++++++++++++++ .../core/cpp/SentryTurboModulePerfLogger.h | 108 ++++++++++ packages/core/cpp/SentryTurboModulePerfSink.h | 98 +++++++++ packages/core/ios/RNSentry.mm | 40 ++++ packages/core/src/js/options.ts | 20 ++ 13 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 packages/core/android/CMakeLists.txt create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java create mode 100644 packages/core/android/src/main/jni/OnLoad.cpp create mode 100644 packages/core/cpp/SentryTurboModulePerfLogger.cpp create mode 100644 packages/core/cpp/SentryTurboModulePerfLogger.h create mode 100644 packages/core/cpp/SentryTurboModulePerfSink.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 70405f192d..aa662328e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Wire Sentry's `facebook::react::NativeModulePerfLogger` on both platforms so the SDK observes every TurboModule lifecycle event (`moduleCreate*`, sync/async method call start/end/fail, execution start/end/fail) for crash attribution, per-module spans and aggregated stats in follow-up releases. Install is automatic — no `OnLoad.cpp` changes on Android. Gated by the new `enableTurboModuleTracking` option on `Sentry.init`, default `false` for this first release. New Architecture only ([#6162](https://github.com/getsentry/sentry-react-native/issues/6162)) - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) - Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) diff --git a/packages/core/RNSentry.podspec b/packages/core/RNSentry.podspec index a454f6a5e1..3bf3c6a81d 100644 --- a/packages/core/RNSentry.podspec +++ b/packages/core/RNSentry.podspec @@ -41,7 +41,11 @@ Pod::Spec.new do |s| s.preserve_paths = '*.js' - s.source_files = 'ios/**/*.{h,m,mm}' + # `cpp/` holds platform-agnostic C++ used by both iOS and Android. On iOS it + # is pulled in here; on Android it is compiled by the dedicated CMake target + # in `android/CMakeLists.txt`. The files are guarded with + # `RCT_NEW_ARCH_ENABLED` so they compile to empty TUs on Old Arch. + s.source_files = 'ios/**/*.{h,m,mm}', 'cpp/**/*.{h,cpp}' s.public_header_files = 'ios/RNSentry.h', 'ios/RNSentrySDK.h', 'ios/RNSentryStart.h', 'ios/RNSentryVersion.h', 'ios/RNSentryBreadcrumb.h', 'ios/RNSentryReplay.h', 'ios/RNSentryReplayBreadcrumbConverter.h', 'ios/Replay/RNSentryReplayMask.h', 'ios/Replay/RNSentryReplayUnmask.h', 'ios/RNSentryTimeToDisplay.h' s.compiler_flags = other_cflags diff --git a/packages/core/android/CMakeLists.txt b/packages/core/android/CMakeLists.txt new file mode 100644 index 0000000000..f0abd0128c --- /dev/null +++ b/packages/core/android/CMakeLists.txt @@ -0,0 +1,62 @@ +# Copyright (c) Sentry. All rights reserved. +# +# Builds `libsentry-tm-perf-logger.so`, the Sentry-owned shared library that +# installs a `facebook::react::NativeModulePerfLogger` into React Native at +# JNI load time. +# +# This CMake target is wired up only when the consuming app is built with +# React Native's New Architecture (the only mode where `TurboModulePerfLogger` +# exists). The gradle script in `build.gradle` enables `externalNativeBuild` +# and `buildFeatures { prefab true }` exclusively when `newArchEnabled` is set, +# so this file is never invoked under Old Arch. + +cmake_minimum_required(VERSION 3.13) +project(sentry-tm-perf-logger CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Build the shared library from the shared C++ source (also compiled into +# `RNSentry.framework` on iOS) plus the Android-specific JNI hook. +add_library( + sentry-tm-perf-logger + SHARED + ../cpp/SentryTurboModulePerfLogger.cpp + src/main/jni/OnLoad.cpp +) + +target_include_directories( + sentry-tm-perf-logger + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../cpp + # ReactAndroid's prefab exposes + # but not the it + # transitively pulls in. Add the source tree's reactperflogger dir to + # plug the gap. `REACT_NATIVE_DIR` is provided by `build.gradle`. + ${REACT_NATIVE_DIR}/ReactCommon/reactperflogger +) + +# `RCT_NEW_ARCH_ENABLED` is the same flag the iOS side checks; the +# implementation in `SentryTurboModulePerfLogger.cpp` keys off it (combined +# with `__ANDROID__`) to decide whether to compile the real install path. +target_compile_definitions( + sentry-tm-perf-logger + PRIVATE + RCT_NEW_ARCH_ENABLED=1 +) + +# Link against React Native's prefab. `reactnative` carries the C++ TurboModule +# infrastructure including `facebook::react::TurboModulePerfLogger`'s +# `enableLogging` entry point and the `NativeModulePerfLogger` base class +# header path. +find_package(ReactAndroid REQUIRED CONFIG) +target_link_libraries( + sentry-tm-perf-logger + PRIVATE + ReactAndroid::reactnative +) + +# Strip symbols in release builds to keep the AAR small. +target_link_options(sentry-tm-perf-logger PRIVATE + "$<$:-Wl,--strip-all>" +) diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 5052b2ef54..0c2f1479e4 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -6,6 +6,26 @@ def isNewArchitectureEnabled() { return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true" } +// Locate the consuming app's `react-native` install. ReactAndroid's prefab +// AAR exposes `` but not the +// `` it transitively `#include`s, +// so we add the source tree's `ReactCommon/reactperflogger` to the include +// path manually. The resolution mirrors `react-native-reanimated`'s helper: +// first honour an explicit `REACT_NATIVE_NODE_MODULES_DIR` override, then +// fall back to `node --print require.resolve(...)` which works in monorepos +// where react-native may be hoisted above the consumer's `node_modules`. +def resolveReactNativeDir() { + def override = safeExtGet("REACT_NATIVE_NODE_MODULES_DIR", null) + if (override != null) { + return file(override) + } + def resolved = providers.exec { + workingDir = rootDir + commandLine("node", "--print", "require.resolve('react-native/package.json')") + }.standardOutput.asText.get().trim() + return file(resolved).parentFile +} + apply plugin: 'com.android.library' if (isNewArchitectureEnabled()) { apply plugin: 'com.facebook.react' @@ -26,6 +46,22 @@ android { } } + // `libsentry-tm-perf-logger.so` installs Sentry's TurboModule perf logger + // at JNI load time. It depends on React Native's `reactnative` prefab + // (which only ships when the New Architecture is enabled), so we wire + // CMake + prefab in only under New Arch. On Old Arch the .so is never + // built and `RNSentryPackage` catches the missing-library error. + if (isNewArchitectureEnabled()) { + buildFeatures { + prefab true + } + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } + } + defaultConfig { minSdkVersion safeExtGet('minSdkVersion', 21) targetSdkVersion safeExtGet('targetSdkVersion', 31) @@ -39,6 +75,17 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() + + if (isNewArchitectureEnabled()) { + def reactNativeDir = resolveReactNativeDir() + externalNativeBuild { + cmake { + cppFlags "-std=c++20", "-fexceptions", "-frtti", "-DRCT_NEW_ARCH_ENABLED=1" + arguments "-DANDROID_STL=c++_shared", + "-DREACT_NATIVE_DIR=${reactNativeDir.absolutePath}" + } + } + } } sourceSets { diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 69501ab5d7..fd2336bcf9 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -192,6 +192,14 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { // Set the React context for the logger so it can forward logs to JS rnLogger.setReactContext(this.reactApplicationContext); + // Toggle the TurboModule perf-logger sink based on the JS option. The + // logger itself is already installed (see `RNSentryPackage`'s static + // initializer + `libsentry-tm-perf-logger.so` JNI hook); this just gates + // whether forwarded callbacks reach the Sentry sink. No-op on Old Arch. + if (rnOptions.hasKey("enableTurboModuleTracking")) { + RNSentryTurboModulePerfTracker.setEnabled(rnOptions.getBoolean("enableTurboModuleTracking")); + } + RNSentryStart.startWithOptions( getApplicationContext(), rnOptions, diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryPackage.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryPackage.java index 1af2fe8c89..97b2036858 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryPackage.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryPackage.java @@ -1,5 +1,6 @@ package io.sentry.react; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.facebook.react.TurboReactPackage; @@ -20,6 +21,27 @@ public class RNSentryPackage extends TurboReactPackage { private static final boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; + static { + // Load `libsentry-tm-perf-logger.so` as early as possible — its + // `JNI_OnLoad` installs Sentry's `facebook::react::NativeModulePerfLogger` + // into React Native so the SDK observes every TurboModule lifecycle event. + // + // The library is only built under New Architecture (see `build.gradle` and + // `CMakeLists.txt`). On Old Architecture there is no TurboModule perf + // logger to install, so a missing `.so` is expected and we swallow the + // `UnsatisfiedLinkError` instead of crashing the host. + try { + System.loadLibrary("sentry-tm-perf-logger"); + } catch (UnsatisfiedLinkError e) { + // Expected on Old Arch and on hosts that strip Sentry's native + // libraries; the SDK keeps working with only Java-side instrumentation. + Log.i( + "RNSentry", + "libsentry-tm-perf-logger.so not loaded; TurboModule perf tracking unavailable: " + + e.getMessage()); + } + } + @Nullable @Override public NativeModule getModule(String name, ReactApplicationContext reactContext) { diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java new file mode 100644 index 0000000000..b6fa8d1b99 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java @@ -0,0 +1,51 @@ +package io.sentry.react; + +import android.util.Log; + +/** + * Thin Java façade over the native runtime flag installed by + * {@code libsentry-tm-perf-logger.so}. + * + *

The native library is only built when the consuming app is using React Native's New + * Architecture (see {@code CMakeLists.txt} and {@code build.gradle}). On Old Architecture the + * underlying {@code .so} is not packaged, so the first call to {@link #setEnabled(boolean)} hits + * an {@link UnsatisfiedLinkError} which we swallow — TurboModule perf tracking is a no-op there. + * + *

We deliberately keep the linkage check lazy (try-catch on first invocation) instead of + * probing at class load time so that the SDK's {@code initNativeSdk} call path stays the single + * source of truth for whether tracking is on. + */ +public final class RNSentryTurboModulePerfTracker { + + private static final String TAG = "RNSentry"; + + /** + * Remembers whether we have already discovered the native symbol to be missing. After the first + * UnsatisfiedLinkError we stop trying — there is no scenario where the link suddenly succeeds + * within the same process lifetime. + */ + private static volatile boolean nativeUnavailable = false; + + private RNSentryTurboModulePerfTracker() {} + + /** + * Toggle the perf-logger sink. When {@code false} (the default) every TurboModule callback the + * logger receives is dropped after one atomic check — there is effectively no overhead. When + * {@code true} the callback is forwarded to whichever sink is currently installed in C++. + */ + public static void setEnabled(boolean enabled) { + if (nativeUnavailable) { + return; + } + try { + nativeSetEnabled(enabled); + } catch (UnsatisfiedLinkError e) { + nativeUnavailable = true; + Log.i( + TAG, + "TurboModule perf-logger native symbol not found; tracking disabled: " + e.getMessage()); + } + } + + private static native void nativeSetEnabled(boolean enabled); +} diff --git a/packages/core/android/src/main/jni/OnLoad.cpp b/packages/core/android/src/main/jni/OnLoad.cpp new file mode 100644 index 0000000000..d65018a5c8 --- /dev/null +++ b/packages/core/android/src/main/jni/OnLoad.cpp @@ -0,0 +1,37 @@ +// Copyright (c) Sentry. All rights reserved. +// +// JNI entry point for the Sentry TurboModule perf-logger shared library. +// +// This shared library (`libsentry-tm-perf-logger.so`) is dedicated to wiring +// up Sentry's `facebook::react::NativeModulePerfLogger` so the SDK observes +// every TurboModule lifecycle event without forcing host apps to modify +// their own `OnLoad.cpp`. +// +// The library is loaded from `RNSentryPackage`'s static initializer via +// `System.loadLibrary("sentry-tm-perf-logger")`, which fires before any +// TurboModule is instantiated by React Native. Inside `JNI_OnLoad` we install +// the perf logger so the very first `moduleDataCreateStart` we see is the +// one for the very first TurboModule the host registers. + +#include + +#include "../../../../cpp/SentryTurboModulePerfLogger.h" + +extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* /*vm*/, void* /*reserved*/) { + // Install the perf logger as soon as the library is loaded. The + // controller is reachable from Java via the implicit-named JNI method + // declared below; we do not register methods explicitly here. + Sentry_InstallTurboModulePerfLogger(); + return JNI_VERSION_1_6; +} + +/// Java-callable runtime toggle for the perf-logger sink. Linked into Java +/// by name (`Java_io_sentry_react_RNSentryTurboModulePerfTracker_nativeSetEnabled`) +/// so we do not need an explicit `RegisterNatives` table. +extern "C" JNIEXPORT void JNICALL +Java_io_sentry_react_RNSentryTurboModulePerfTracker_nativeSetEnabled( + JNIEnv* /*env*/, + jclass /*clazz*/, + jboolean enabled) { + Sentry_SetTurboModuleTrackingEnabled(enabled ? 1 : 0); +} diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.cpp b/packages/core/cpp/SentryTurboModulePerfLogger.cpp new file mode 100644 index 0000000000..c5e5bb5ed2 --- /dev/null +++ b/packages/core/cpp/SentryTurboModulePerfLogger.cpp @@ -0,0 +1,198 @@ +// Copyright (c) Sentry. All rights reserved. +// +// TurboModule-based perf logging is a New Architecture concept; on Old Arch +// there is no `facebook::react::TurboModulePerfLogger` to install into. We +// still compile the controller on Old Arch (sink/enable state lives there) +// but `install()` is a no-op so the runtime never tries to call into a header +// the toolchain didn't compile against. + +#include "SentryTurboModulePerfLogger.h" + +#if defined(RCT_NEW_ARCH_ENABLED) || defined(__ANDROID__) +# define SENTRY_TM_PERF_LOGGER_AVAILABLE 1 +#else +# define SENTRY_TM_PERF_LOGGER_AVAILABLE 0 +#endif + +#if SENTRY_TM_PERF_LOGGER_AVAILABLE +# include +# include +#endif + +#include +#include +#include + +namespace sentry::reactnative { + +#if SENTRY_TM_PERF_LOGGER_AVAILABLE + +namespace { + +/// Concrete `NativeModulePerfLogger` subclass we hand to React Native. It owns +/// no state of its own — every callback goes through +/// `SentryTurboModulePerfController` so the sink and the runtime flag can be +/// swapped without re-installing the logger. +class ForwardingLogger final : public facebook::react::NativeModulePerfLogger { + public: + // The macro below lets us keep this file readable. Without it we'd have + // ~30 near-identical 5-line method bodies; with it the surface fits on one + // screen and any divergence between RN's API and ours surfaces as a compile + // error rather than a silent drop. +#define SENTRY_FORWARD0(name) \ + void name() override { \ + auto& c = SentryTurboModulePerfController::instance(); \ + if (!c.isEnabled()) { \ + return; \ + } \ + if (auto sink = c.sink()) { \ + sink->name(); \ + } \ + } + +#define SENTRY_FORWARD1(name, arg1Type, arg1Name) \ + void name(arg1Type arg1Name) override { \ + auto& c = SentryTurboModulePerfController::instance(); \ + if (!c.isEnabled()) { \ + return; \ + } \ + if (auto sink = c.sink()) { \ + sink->name(arg1Name); \ + } \ + } + +#define SENTRY_FORWARD2(name, t1, n1, t2, n2) \ + void name(t1 n1, t2 n2) override { \ + auto& c = SentryTurboModulePerfController::instance(); \ + if (!c.isEnabled()) { \ + return; \ + } \ + if (auto sink = c.sink()) { \ + sink->name(n1, n2); \ + } \ + } + +#define SENTRY_FORWARD3(name, t1, n1, t2, n2, t3, n3) \ + void name(t1 n1, t2 n2, t3 n3) override { \ + auto& c = SentryTurboModulePerfController::instance(); \ + if (!c.isEnabled()) { \ + return; \ + } \ + if (auto sink = c.sink()) { \ + sink->name(n1, n2, n3); \ + } \ + } + + // Module data / create + SENTRY_FORWARD2(moduleDataCreateStart, const char*, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleDataCreateEnd, const char*, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateStart, const char*, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateCacheHit, const char*, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateConstructStart, const char*, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateConstructEnd, const char*, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateSetUpStart, const char*, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateSetUpEnd, const char*, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateEnd, const char*, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateFail, const char*, moduleName, int32_t, id) + + // JS require timings + SENTRY_FORWARD1(moduleJSRequireBeginningStart, const char*, moduleName) + SENTRY_FORWARD1(moduleJSRequireBeginningCacheHit, const char*, moduleName) + SENTRY_FORWARD1(moduleJSRequireBeginningEnd, const char*, moduleName) + SENTRY_FORWARD1(moduleJSRequireBeginningFail, const char*, moduleName) + SENTRY_FORWARD1(moduleJSRequireEndingStart, const char*, moduleName) + SENTRY_FORWARD1(moduleJSRequireEndingEnd, const char*, moduleName) + SENTRY_FORWARD1(moduleJSRequireEndingFail, const char*, moduleName) + + // Sync method calls + SENTRY_FORWARD2(syncMethodCallStart, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(syncMethodCallArgConversionStart, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(syncMethodCallArgConversionEnd, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(syncMethodCallExecutionStart, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(syncMethodCallExecutionEnd, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(syncMethodCallReturnConversionStart, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(syncMethodCallReturnConversionEnd, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(syncMethodCallEnd, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(syncMethodCallFail, const char*, moduleName, const char*, methodName) + + // Async method calls (call half) + SENTRY_FORWARD2(asyncMethodCallStart, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(asyncMethodCallArgConversionStart, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(asyncMethodCallArgConversionEnd, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(asyncMethodCallDispatch, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(asyncMethodCallEnd, const char*, moduleName, const char*, methodName) + SENTRY_FORWARD2(asyncMethodCallFail, const char*, moduleName, const char*, methodName) + + // Async batch preprocess + SENTRY_FORWARD0(asyncMethodCallBatchPreprocessStart) + SENTRY_FORWARD1(asyncMethodCallBatchPreprocessEnd, int, batchSize) + + // Async method calls (execution half) + SENTRY_FORWARD3(asyncMethodCallExecutionStart, const char*, moduleName, const char*, methodName, int32_t, id) + SENTRY_FORWARD3(asyncMethodCallExecutionArgConversionStart, const char*, moduleName, const char*, methodName, int32_t, id) + SENTRY_FORWARD3(asyncMethodCallExecutionArgConversionEnd, const char*, moduleName, const char*, methodName, int32_t, id) + SENTRY_FORWARD3(asyncMethodCallExecutionEnd, const char*, moduleName, const char*, methodName, int32_t, id) + SENTRY_FORWARD3(asyncMethodCallExecutionFail, const char*, moduleName, const char*, methodName, int32_t, id) + +#undef SENTRY_FORWARD0 +#undef SENTRY_FORWARD1 +#undef SENTRY_FORWARD2 +#undef SENTRY_FORWARD3 +}; + +} // namespace + +#endif // SENTRY_TM_PERF_LOGGER_AVAILABLE + +SentryTurboModulePerfController& SentryTurboModulePerfController::instance() noexcept { + // Function-local static — guaranteed thread-safe initialisation since C++11, + // and avoids the static-initialisation-order fiasco that bites global singletons + // hand-rolled in this kind of native-bridge code. + static SentryTurboModulePerfController controller; + return controller; +} + +void SentryTurboModulePerfController::install() noexcept { +#if SENTRY_TM_PERF_LOGGER_AVAILABLE + // `compare_exchange_strong` makes the install idempotent across competing + // threads: only the first caller transitions `installed_` from `false` to + // `true`, and only that caller hands the logger off to React Native. + bool expected = false; + if (!installed_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { + return; + } + facebook::react::TurboModulePerfLogger::enableLogging(std::make_unique()); +#endif +} + +void SentryTurboModulePerfController::setSink(std::shared_ptr sink) noexcept { + std::lock_guard lock(sink_mutex_); + sink_ = std::move(sink); +} + +std::shared_ptr SentryTurboModulePerfController::sink() const noexcept { + std::lock_guard lock(sink_mutex_); + return sink_; +} + +void SentryTurboModulePerfController::setEnabled(bool enabled) noexcept { + enabled_.store(enabled, std::memory_order_release); +} + +bool SentryTurboModulePerfController::isEnabled() const noexcept { + return enabled_.load(std::memory_order_acquire); +} + +} // namespace sentry::reactnative + +extern "C" { + +void Sentry_InstallTurboModulePerfLogger(void) { + sentry::reactnative::SentryTurboModulePerfController::instance().install(); +} + +void Sentry_SetTurboModuleTrackingEnabled(int enabled) { + sentry::reactnative::SentryTurboModulePerfController::instance().setEnabled(enabled != 0); +} + +} // extern "C" diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.h b/packages/core/cpp/SentryTurboModulePerfLogger.h new file mode 100644 index 0000000000..3ccf558c75 --- /dev/null +++ b/packages/core/cpp/SentryTurboModulePerfLogger.h @@ -0,0 +1,108 @@ +// Copyright (c) Sentry. All rights reserved. +// +// Sentry's `facebook::react::NativeModulePerfLogger` implementation, plus the +// one-call installer used by the platform glue (`RNSentry.mm` on iOS, the JNI +// shared library `libsentry-tm-perf-logger.so` on Android). +// +// React Native's TurboModule infrastructure calls a single, process-wide +// `NativeModulePerfLogger` for every TurboModule lifecycle event. Only one +// logger can be installed at a time — RN's `TurboModulePerfLogger::enableLogging` +// replaces whatever was installed before. Hosts that already install their +// own logger will lose Sentry's observability after this point; that's the +// trade-off the issue acknowledges (the alternative would require a hook RN +// doesn't expose). +// +// The logger here is a thin forwarder: +// - When the runtime `enabled` flag is `false` (default for the first +// release), every callback fast-paths to a `return` after one atomic load. +// - When `true`, the callback is forwarded to the currently installed sink, +// if any. +// +// The sink is swappable at runtime (`setSink`) so the higher-level features +// (per-Turbo-Module spans, JS↔Native crash attribution, aggregated stats) can +// each ship their own sink in follow-up issues without revisiting the install +// path. + +#pragma once + +#include "SentryTurboModulePerfSink.h" + +#include +#include +#include + +namespace sentry::reactnative { + +class SentryTurboModulePerfLogger; + +/// Sentry-owned `NativeModulePerfLogger` (declared as the React Native type in +/// the .cpp to keep this header free of React headers — the .cpp brings in +/// `` and ``). +/// +/// Install via `Sentry_InstallTurboModulePerfLogger()` (defined in this header +/// as a C-linkage symbol so the JNI side can call it from `JNI_OnLoad` +/// without dragging the C++ ABI through the JNI boundary). +class SentryTurboModulePerfController { + public: + /// Returns the process-wide controller instance. The controller owns the + /// installed logger and the active sink. + static SentryTurboModulePerfController& instance() noexcept; + + /// Idempotent install. The first call constructs a `SentryTurboModulePerfLogger` + /// and hands it to RN via `facebook::react::TurboModulePerfLogger::enableLogging`. + /// Subsequent calls are no-ops — this matters on iOS, where the SDK can be + /// re-initialised by tests and on Android where the JNI library may be loaded + /// more than once across the lifetime of a host process. + void install() noexcept; + + /// Swap the sink that receives forwarded callbacks. Pass `nullptr` to detach. + /// Thread-safe; uses an atomic shared-pointer swap. + void setSink(std::shared_ptr sink) noexcept; + + /// Read the currently installed sink, or `nullptr` if none. The returned + /// pointer is captured at the moment of call and remains valid for the + /// caller's reference count even if a concurrent `setSink` swaps the sink. + std::shared_ptr sink() const noexcept; + + /// Runtime enable / disable. Defaults to `false`. When `false`, the logger + /// fast-paths every callback to a single atomic load — no virtual dispatch, + /// no sink lookup. This is the gate the public `enableTurboModuleTracking` + /// JS option toggles. + void setEnabled(bool enabled) noexcept; + bool isEnabled() const noexcept; + + private: + SentryTurboModulePerfController() noexcept = default; + + std::atomic installed_{false}; + std::atomic enabled_{false}; + + // Sink storage. We use a raw mutex + shared_ptr rather than + // `std::atomic>` because the latter is C++20 and not + // available on the older toolchains some downstream RN setups still use. + mutable std::mutex sink_mutex_; + std::shared_ptr sink_; +}; + +} // namespace sentry::reactnative + +#ifdef __cplusplus +extern "C" { +#endif + +/// One-call installer. Safe to call multiple times. +/// +/// - On iOS we call this from `RNSentry`'s init path so the logger is in place +/// before the bridge starts creating modules. +/// - On Android we call this from `JNI_OnLoad` inside `libsentry-tm-perf-logger.so`, +/// which is loaded by `RNSentryPackage`'s static initializer. +void Sentry_InstallTurboModulePerfLogger(void); + +/// Runtime flag toggled from JS via `RNSentry.enableTurboModuleTracking`. The +/// underlying logger is always installed (so we don't miss the early lifecycle +/// events); this gate just decides whether forwarded callbacks reach the sink. +void Sentry_SetTurboModuleTrackingEnabled(int enabled); + +#ifdef __cplusplus +} // extern "C" +#endif diff --git a/packages/core/cpp/SentryTurboModulePerfSink.h b/packages/core/cpp/SentryTurboModulePerfSink.h new file mode 100644 index 0000000000..95c9078da6 --- /dev/null +++ b/packages/core/cpp/SentryTurboModulePerfSink.h @@ -0,0 +1,98 @@ +// Copyright (c) Sentry. All rights reserved. +// +// Pluggable sink for `SentryTurboModulePerfLogger`. +// +// `SentryTurboModulePerfLogger` is the single Sentry-owned implementation of +// `facebook::react::NativeModulePerfLogger`; it receives every TurboModule +// lifecycle callback that React Native fires. The logger does not do anything +// useful on its own — it only forwards each callback to whatever sink is +// installed. +// +// Follow-up features plug into this hook to build their own behavior: +// - JS↔Native crash attribution (sets the current module/method on the scope +// so a native crash inside `Foo.bar()` carries `turbo_module.name = Foo` / +// `turbo_module.method = bar`). +// - Per-Turbo-Module spans (opens a span around each method invocation). +// - Aggregated stats (counts / duration histograms per module/method). +// +// The sink owns all real work; the logger only adapts the C++ ABI. This keeps +// the foundation PR small and lets each follow-up feature ship its own sink +// without touching the install path. + +#pragma once + +#include + +namespace sentry::reactnative { + +/// Sink interface that consumes every TurboModule perf event the SDK observes. +/// +/// All methods are invoked on the React Native thread that's executing the +/// matching TurboModule lifecycle step — usually the JS thread for the sync +/// surface and the native module's serial executor for the async surface. +/// Implementations MUST be thread-safe and MUST NOT block: a slow sink will +/// directly inflate every native module call in the app. +/// +/// Pointers passed in (`moduleName`, `methodName`) are owned by React Native; +/// the sink may inspect them during the call but MUST NOT retain them past it. +class ISentryTurboModulePerfSink { + public: + virtual ~ISentryTurboModulePerfSink() = default; + + // ---- Module data / create (iOS NativeModule two-phase, Android single phase) + virtual void moduleDataCreateStart(const char* moduleName, int32_t id) = 0; + virtual void moduleDataCreateEnd(const char* moduleName, int32_t id) = 0; + virtual void moduleCreateStart(const char* moduleName, int32_t id) = 0; + virtual void moduleCreateCacheHit(const char* moduleName, int32_t id) = 0; + virtual void moduleCreateConstructStart(const char* moduleName, int32_t id) = 0; + virtual void moduleCreateConstructEnd(const char* moduleName, int32_t id) = 0; + virtual void moduleCreateSetUpStart(const char* moduleName, int32_t id) = 0; + virtual void moduleCreateSetUpEnd(const char* moduleName, int32_t id) = 0; + virtual void moduleCreateEnd(const char* moduleName, int32_t id) = 0; + virtual void moduleCreateFail(const char* moduleName, int32_t id) = 0; + + // ---- JS require timings (separate from create — they bracket the `require()` call itself) + virtual void moduleJSRequireBeginningStart(const char* moduleName) = 0; + virtual void moduleJSRequireBeginningCacheHit(const char* moduleName) = 0; + virtual void moduleJSRequireBeginningEnd(const char* moduleName) = 0; + virtual void moduleJSRequireBeginningFail(const char* moduleName) = 0; + virtual void moduleJSRequireEndingStart(const char* moduleName) = 0; + virtual void moduleJSRequireEndingEnd(const char* moduleName) = 0; + virtual void moduleJSRequireEndingFail(const char* moduleName) = 0; + + // ---- Sync method calls (blocking from JS) + virtual void syncMethodCallStart(const char* moduleName, const char* methodName) = 0; + virtual void syncMethodCallArgConversionStart(const char* moduleName, const char* methodName) = 0; + virtual void syncMethodCallArgConversionEnd(const char* moduleName, const char* methodName) = 0; + virtual void syncMethodCallExecutionStart(const char* moduleName, const char* methodName) = 0; + virtual void syncMethodCallExecutionEnd(const char* moduleName, const char* methodName) = 0; + virtual void syncMethodCallReturnConversionStart(const char* moduleName, const char* methodName) = 0; + virtual void syncMethodCallReturnConversionEnd(const char* moduleName, const char* methodName) = 0; + virtual void syncMethodCallEnd(const char* moduleName, const char* methodName) = 0; + virtual void syncMethodCallFail(const char* moduleName, const char* methodName) = 0; + + // ---- Async method calls (Promise-returning from JS) + // + // The async surface is split into two halves: + // - The "call" half fires on the JS thread (`asyncMethodCall{Start,Dispatch,End,Fail}`). + // - The "execution" half fires on the native module's executor when the + // queued call actually runs (`asyncMethodCallExecution{Start,End,Fail}`), + // carrying an `id` to correlate the two halves. + virtual void asyncMethodCallStart(const char* moduleName, const char* methodName) = 0; + virtual void asyncMethodCallArgConversionStart(const char* moduleName, const char* methodName) = 0; + virtual void asyncMethodCallArgConversionEnd(const char* moduleName, const char* methodName) = 0; + virtual void asyncMethodCallDispatch(const char* moduleName, const char* methodName) = 0; + virtual void asyncMethodCallEnd(const char* moduleName, const char* methodName) = 0; + virtual void asyncMethodCallFail(const char* moduleName, const char* methodName) = 0; + + virtual void asyncMethodCallBatchPreprocessStart() = 0; + virtual void asyncMethodCallBatchPreprocessEnd(int batchSize) = 0; + + virtual void asyncMethodCallExecutionStart(const char* moduleName, const char* methodName, int32_t id) = 0; + virtual void asyncMethodCallExecutionArgConversionStart(const char* moduleName, const char* methodName, int32_t id) = 0; + virtual void asyncMethodCallExecutionArgConversionEnd(const char* moduleName, const char* methodName, int32_t id) = 0; + virtual void asyncMethodCallExecutionEnd(const char* moduleName, const char* methodName, int32_t id) = 0; + virtual void asyncMethodCallExecutionFail(const char* moduleName, const char* methodName, int32_t id) = 0; +}; + +} // namespace sentry::reactnative diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index c64cc6bb5e..2437e61a63 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -58,10 +58,36 @@ - (instancetype)initWithDictionary:(NSDictionary *)dictionary; #import "RNSentryStart.h" #import "RNSentryVersion.h" #import "SentrySDKWrapper.h" + +// TurboModule perf logger — only available on New Architecture, but we always +// include the header so the `Sentry_SetTurboModuleTrackingEnabled` toggle +// compiles on Old Arch too (it's a no-op there). +#import "../cpp/SentryTurboModulePerfLogger.h" #import "SentryScreenFramesWrapper.h" static bool hasFetchedAppStart; +// Install the TurboModule perf logger as early as possible. The `+load` method +// on `RNSentry` itself is reserved by `RCT_EXPORT_MODULE()` (which generates +// its own `+load` to register the module with React Native), so we host the +// install hook on a separate dummy class. Both `+load`s run before any module +// instantiation, so the order between them does not matter — we just need +// ours to fire before `RCTBridge` / `RCTHost` create their first TurboModule. +// +// The install is idempotent (the controller short-circuits on subsequent +// calls) and free when the `enableTurboModuleTracking` runtime flag is off, +// which is the default. On Old Architecture this compiles to a no-op +// installer. +@interface RNSentryTurboModulePerfLoggerInstaller : NSObject +@end + +@implementation RNSentryTurboModulePerfLoggerInstaller ++ (void)load +{ + Sentry_InstallTurboModulePerfLogger(); +} +@end + @implementation RNSentry { bool hasListeners; bool _shakeDetectionEnabled; @@ -138,6 +164,11 @@ - (NSMutableDictionary *)prepareOptions:(NSDictionary *)options [mutableOptions removeObjectForKey:@"tracesSampler"]; [mutableOptions removeObjectForKey:@"enableTracing"]; + // `enableTurboModuleTracking` is consumed by `initNativeSdk` before this + // dict reaches sentry-cocoa; strip so it does not leak into + // SentryOptions (which would not know what to do with it). + [mutableOptions removeObjectForKey:@"enableTurboModuleTracking"]; + [self trySetIgnoreErrors:mutableOptions]; return mutableOptions; @@ -148,6 +179,15 @@ - (NSMutableDictionary *)prepareOptions:(NSDictionary *)options RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { NSMutableDictionary *mutableOptions = [self prepareOptions:options]; + + // Toggle the TurboModule perf-logger sink based on the JS option. The + // logger itself is already installed (see +load); this just decides + // whether forwarded callbacks reach the Sentry sink. + id enableTurboModuleTracking = [options objectForKey:@"enableTurboModuleTracking"]; + if ([enableTurboModuleTracking isKindOfClass:[NSNumber class]]) { + Sentry_SetTurboModuleTrackingEnabled([(NSNumber *)enableTurboModuleTracking boolValue] ? 1 : 0); + } + NSError *error = nil; [RNSentryStart startWithOptions:mutableOptions error:&error]; if (error != nil) { diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index e3593cf465..16d1e8fba5 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -288,6 +288,26 @@ export interface BaseReactNativeOptions { */ enableStallTracking?: boolean; + /** + * Install Sentry's native `TurboModulePerfLogger` and forward every Turbo + * Module lifecycle callback (`moduleCreate*`, sync/async method call + * start/end/fail, execution start/end/fail) to the higher-level Sentry + * instrumentation (crash attribution, per-module spans, aggregated stats). + * + * Only takes effect on React Native New Architecture. On Old Architecture + * this option is a no-op. + * + * The native perf logger is always installed at SDK load time so we never + * miss the earliest module-create events; this flag only gates whether + * forwarded callbacks actually reach the Sentry sink. Off by default + * because the higher-level features building on top of this hook ship in + * follow-up releases. + * + * @default false + * @experimental + */ + enableTurboModuleTracking?: boolean; + /** * Trace User Interaction events like touch and gestures. * From 97df93fe373988e0dda9ab32616a9047e93b7ec5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 18 Jun 2026 10:36:14 +0200 Subject: [PATCH 02/22] test(turbomodule): Cover perf-logger controller and JVM tracker latch Address Warden's medium-severity finding on PR #6307: the new `SentryTurboModulePerfController` and `RNSentryTurboModulePerfTracker` shipped without unit coverage. Add focused tests that exercise the state machines independently of React Native's runtime. - **iOS** (`RNSentryCocoaTester/.../RNSentryTurboModulePerfControllerTests.mm`): default `isEnabled() == false`, `setEnabled` toggle, the C-linkage `Sentry_SetTurboModuleTrackingEnabled` entry point matches the typed setter, `setSink`/`sink` round-trips including `nullptr` detach, and `Sentry_InstallTurboModulePerfLogger` idempotency under repeated calls. End-to-end forwarding through `facebook::react::TurboModulePerfLogger` is intentionally not covered here \u2014 it requires `+load` ordering and process-wide singletons that the follow-up sink PRs will integration-test. - **Android** (`RNSentryAndroidTester/.../RNSentryTurboModulePerfTrackerTest.kt`): the JVM-side latch around the JNI symbol. In the test JVM the underlying `.so` is not loaded, so the first `setEnabled` call must catch `UnsatisfiedLinkError` and flip `nativeUnavailable`; subsequent calls must short-circuit. Uses Robolectric so the `android.util.Log.i` call inside the catch branch resolves instead of throwing the not-mocked stub. A small `@TestOnly` window on the tracker exposes the latch state to assertions. Also fix the changelog entry to reference the PR (#6307) rather than the issue (#6162) so danger stops nagging. --- CHANGELOG.md | 2 +- .../RNSentryTurboModulePerfTrackerTest.kt | 79 ++++++++ .../project.pbxproj | 12 +- .../RNSentryTurboModulePerfControllerTests.mm | 168 ++++++++++++++++++ .../react/RNSentryTurboModulePerfTracker.java | 11 ++ 5 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt create mode 100644 packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm diff --git a/CHANGELOG.md b/CHANGELOG.md index aa662328e3..39512a00c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- Wire Sentry's `facebook::react::NativeModulePerfLogger` on both platforms so the SDK observes every TurboModule lifecycle event (`moduleCreate*`, sync/async method call start/end/fail, execution start/end/fail) for crash attribution, per-module spans and aggregated stats in follow-up releases. Install is automatic — no `OnLoad.cpp` changes on Android. Gated by the new `enableTurboModuleTracking` option on `Sentry.init`, default `false` for this first release. New Architecture only ([#6162](https://github.com/getsentry/sentry-react-native/issues/6162)) +- Wire Sentry's `facebook::react::NativeModulePerfLogger` on both platforms so the SDK observes every TurboModule lifecycle event (`moduleCreate*`, sync/async method call start/end/fail, execution start/end/fail) for crash attribution, per-module spans and aggregated stats in follow-up releases. Install is automatic — no `OnLoad.cpp` changes on Android. Gated by the new `enableTurboModuleTracking` option on `Sentry.init`, default `false` for this first release. New Architecture only ([#6307](https://github.com/getsentry/sentry-react-native/pull/6307)) - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) - Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt new file mode 100644 index 0000000000..8cdd170fb7 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt @@ -0,0 +1,79 @@ +package io.sentry.rnsentryandroidtester + +import io.sentry.react.RNSentryTurboModulePerfTracker +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit coverage for the JVM-side wrapper around the native perf-logger toggle. + * + * In a host JVM (where this test runs) there is no Android system loader for + * `libsentry-tm-perf-logger.so`, so any call into the native method must throw + * `UnsatisfiedLinkError`. The tracker is expected to swallow that error and + * flip an internal latch so subsequent calls short-circuit without retrying. + */ +// Robolectric runner so the `android.util.Log` call inside the tracker's +// `catch` branch resolves to a real implementation instead of the +// default-not-mocked stub the bare JUnit4 runner exposes. +@RunWith(RobolectricTestRunner::class) +class RNSentryTurboModulePerfTrackerTest { + @Before + fun resetLatch() { + // Each test exercises the latch transition from scratch; without this + // reset the second test in execution order would see the latch already + // tripped from the previous one. + RNSentryTurboModulePerfTracker.resetNativeUnavailableForTests() + } + + @After + fun cleanUp() { + RNSentryTurboModulePerfTracker.resetNativeUnavailableForTests() + } + + @Test + fun setEnabledSwallowsUnsatisfiedLinkErrorOnFirstCall() { + // No `.so` loaded in the test JVM → the JNI symbol is missing. The + // tracker must absorb the resulting `UnsatisfiedLinkError` so the + // caller does not see a crash on a misconfigured host. + RNSentryTurboModulePerfTracker.setEnabled(true) + // Reaching this point means the error was caught, which is the contract. + assertTrue( + "after a failed link, the tracker must latch the failure", + RNSentryTurboModulePerfTracker.isNativeUnavailableForTests(), + ) + } + + @Test + fun subsequentCallsShortCircuitAfterLatchTrips() { + // Trip the latch via the first call. + RNSentryTurboModulePerfTracker.setEnabled(true) + assertTrue(RNSentryTurboModulePerfTracker.isNativeUnavailableForTests()) + + // The second call must not throw or attempt to relink. The contract is + // "exactly one UnsatisfiedLinkError per process lifetime" — anything + // else means the tracker is hammering the runtime on every setEnabled. + RNSentryTurboModulePerfTracker.setEnabled(false) + RNSentryTurboModulePerfTracker.setEnabled(true) + assertTrue( + "latch must stay tripped across repeated calls", + RNSentryTurboModulePerfTracker.isNativeUnavailableForTests(), + ) + } + + @Test + fun resetClearsTheLatch() { + RNSentryTurboModulePerfTracker.setEnabled(true) + assertTrue(RNSentryTurboModulePerfTracker.isNativeUnavailableForTests()) + + RNSentryTurboModulePerfTracker.resetNativeUnavailableForTests() + assertFalse( + "the @TestOnly reset must clear the latch so tests can re-exercise it", + RNSentryTurboModulePerfTracker.isNativeUnavailableForTests(), + ) + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 9abee4aef3..a466e484cd 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ 332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332D33462CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift */; }; 3339C4812D6625570088EB3A /* RNSentryUserTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3339C4802D6625570088EB3A /* RNSentryUserTests.m */; }; - B4DEB41739F14AA38202D4D4 /* RNSentryUriValidationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */; }; 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */; }; 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */; }; 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; @@ -18,7 +17,9 @@ 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */; }; 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */; }; 33F58AD02977037D008F60EA /* RNSentryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.m */; }; + A1B2C3D4E5F600000000001 /* RNSentryTurboModulePerfControllerTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600000000002 /* RNSentryTurboModulePerfControllerTests.mm */; }; AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */; }; + B4DEB41739F14AA38202D4D4 /* RNSentryUriValidationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */; }; B5859A50A3E865EF5E61465A /* libPods-RNSentryCocoaTesterTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */; }; /* End PBXBuildFile section */ @@ -31,7 +32,6 @@ 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryCocoaTesterTests-Bridging-Header.h"; sourceTree = ""; }; 3339C47F2D6625260088EB3A /* RNSentry+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentry+Test.h"; sourceTree = ""; }; 3339C4802D6625570088EB3A /* RNSentryUserTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryUserTests.m; sourceTree = ""; }; - 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryUriValidationTests.m; sourceTree = ""; }; 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNSentryReplayBreadcrumbConverterTests.swift; sourceTree = ""; }; 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayBreadcrumbConverter.h; path = ../ios/RNSentryReplayBreadcrumbConverter.h; sourceTree = ""; }; 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryBreadcrumbTests.swift; sourceTree = ""; }; @@ -50,7 +50,9 @@ 33DEDFEE2D8DD431006066E4 /* RNSentryTimeToDisplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryTimeToDisplay.h; path = ../ios/RNSentryTimeToDisplay.h; sourceTree = SOURCE_ROOT; }; 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryTimeToDisplayTests.swift; sourceTree = ""; }; 33F58ACF2977037D008F60EA /* RNSentryTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNSentryTests.m; sourceTree = ""; }; + 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryUriValidationTests.m; sourceTree = ""; }; 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNSentryCocoaTesterTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + A1B2C3D4E5F600000000002 /* RNSentryTurboModulePerfControllerTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RNSentryTurboModulePerfControllerTests.mm; sourceTree = ""; }; E2321E7CFA55AB617247098E /* Pods-RNSentryCocoaTesterTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNSentryCocoaTesterTests.debug.xcconfig"; path = "Target Support Files/Pods-RNSentryCocoaTesterTests/Pods-RNSentryCocoaTesterTests.debug.xcconfig"; sourceTree = ""; }; F48F26542EA2A481008A185E /* RNSentryEmitNewFrameEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryEmitNewFrameEvent.h; path = ../ios/RNSentryEmitNewFrameEvent.h; sourceTree = SOURCE_ROOT; }; F48F26552EA2A4D4008A185E /* RNSentryFramesTrackerListener.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryFramesTrackerListener.h; path = ../ios/RNSentryFramesTrackerListener.h; sourceTree = SOURCE_ROOT; }; @@ -111,6 +113,7 @@ 33F58ACF2977037D008F60EA /* RNSentryTests.m */, 3339C4802D6625570088EB3A /* RNSentryUserTests.m */, 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */, + A1B2C3D4E5F600000000002 /* RNSentryTurboModulePerfControllerTests.mm */, 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */, 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */, 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */, @@ -241,14 +244,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNSentryCocoaTesterTests/Pods-RNSentryCocoaTesterTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-RNSentryCocoaTesterTests/Pods-RNSentryCocoaTesterTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-RNSentryCocoaTesterTests/Pods-RNSentryCocoaTesterTests-resources.sh\"\n"; @@ -270,6 +269,7 @@ 33F58AD02977037D008F60EA /* RNSentryTests.m in Sources */, 3339C4812D6625570088EB3A /* RNSentryUserTests.m in Sources */, B4DEB41739F14AA38202D4D4 /* RNSentryUriValidationTests.m in Sources */, + A1B2C3D4E5F600000000001 /* RNSentryTurboModulePerfControllerTests.mm in Sources */, 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */, 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */, 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */, diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm new file mode 100644 index 0000000000..46fe444fee --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm @@ -0,0 +1,168 @@ +// Unit coverage for the C++ controller that backs the TurboModule perf +// logger on both platforms. +// +// The controller is exercised here through the same C entry points the +// platform glue uses (`Sentry_InstallTurboModulePerfLogger`, +// `Sentry_SetTurboModuleTrackingEnabled`) plus the typed `setSink`/`sink` +// API. We cover state transitions only; the full callback fan-out is +// implicit in `ForwardingLogger`'s use of these primitives. +// +// The tests run on iOS New Architecture (the RNSentryCocoaTester target), +// where `RCT_NEW_ARCH_ENABLED` is defined and the underlying RN headers are +// available. + +#import + +#import +#import + +#import "../../cpp/SentryTurboModulePerfLogger.h" +#import "../../cpp/SentryTurboModulePerfSink.h" + +using sentry::reactnative::ISentryTurboModulePerfSink; +using sentry::reactnative::SentryTurboModulePerfController; + +namespace { + +/// Test double that records each forwarded call. We only need a couple of +/// counters here — the goal is to verify that the controller actually routes +/// events to the installed sink, not to exhaustively cover every RN callback. +class RecordingSink : public ISentryTurboModulePerfSink { + public: + std::atomic moduleCreateStartCalls{0}; + std::atomic syncMethodCallStartCalls{0}; + + void moduleDataCreateStart(const char* /*moduleName*/, int32_t /*id*/) override {} + void moduleDataCreateEnd(const char* /*moduleName*/, int32_t /*id*/) override {} + void moduleCreateStart(const char* /*moduleName*/, int32_t /*id*/) override { + moduleCreateStartCalls.fetch_add(1, std::memory_order_relaxed); + } + void moduleCreateCacheHit(const char* /*moduleName*/, int32_t /*id*/) override {} + void moduleCreateConstructStart(const char* /*moduleName*/, int32_t /*id*/) override {} + void moduleCreateConstructEnd(const char* /*moduleName*/, int32_t /*id*/) override {} + void moduleCreateSetUpStart(const char* /*moduleName*/, int32_t /*id*/) override {} + void moduleCreateSetUpEnd(const char* /*moduleName*/, int32_t /*id*/) override {} + void moduleCreateEnd(const char* /*moduleName*/, int32_t /*id*/) override {} + void moduleCreateFail(const char* /*moduleName*/, int32_t /*id*/) override {} + + void moduleJSRequireBeginningStart(const char* /*moduleName*/) override {} + void moduleJSRequireBeginningCacheHit(const char* /*moduleName*/) override {} + void moduleJSRequireBeginningEnd(const char* /*moduleName*/) override {} + void moduleJSRequireBeginningFail(const char* /*moduleName*/) override {} + void moduleJSRequireEndingStart(const char* /*moduleName*/) override {} + void moduleJSRequireEndingEnd(const char* /*moduleName*/) override {} + void moduleJSRequireEndingFail(const char* /*moduleName*/) override {} + + void syncMethodCallStart(const char* /*moduleName*/, const char* /*methodName*/) override { + syncMethodCallStartCalls.fetch_add(1, std::memory_order_relaxed); + } + void syncMethodCallArgConversionStart(const char* /*moduleName*/, const char* /*methodName*/) override {} + void syncMethodCallArgConversionEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} + void syncMethodCallExecutionStart(const char* /*moduleName*/, const char* /*methodName*/) override {} + void syncMethodCallExecutionEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} + void syncMethodCallReturnConversionStart(const char* /*moduleName*/, const char* /*methodName*/) override {} + void syncMethodCallReturnConversionEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} + void syncMethodCallEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} + void syncMethodCallFail(const char* /*moduleName*/, const char* /*methodName*/) override {} + + void asyncMethodCallStart(const char* /*moduleName*/, const char* /*methodName*/) override {} + void asyncMethodCallArgConversionStart(const char* /*moduleName*/, const char* /*methodName*/) override {} + void asyncMethodCallArgConversionEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} + void asyncMethodCallDispatch(const char* /*moduleName*/, const char* /*methodName*/) override {} + void asyncMethodCallEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} + void asyncMethodCallFail(const char* /*moduleName*/, const char* /*methodName*/) override {} + + void asyncMethodCallBatchPreprocessStart() override {} + void asyncMethodCallBatchPreprocessEnd(int /*batchSize*/) override {} + + void asyncMethodCallExecutionStart(const char* /*moduleName*/, const char* /*methodName*/, int32_t /*id*/) override {} + void asyncMethodCallExecutionArgConversionStart(const char* /*moduleName*/, const char* /*methodName*/, int32_t /*id*/) override {} + void asyncMethodCallExecutionArgConversionEnd(const char* /*moduleName*/, const char* /*methodName*/, int32_t /*id*/) override {} + void asyncMethodCallExecutionEnd(const char* /*moduleName*/, const char* /*methodName*/, int32_t /*id*/) override {} + void asyncMethodCallExecutionFail(const char* /*moduleName*/, const char* /*methodName*/, int32_t /*id*/) override {} +}; + +} // namespace + +@interface RNSentryTurboModulePerfControllerTests : XCTestCase +@end + +@implementation RNSentryTurboModulePerfControllerTests + +- (void)setUp +{ + // The controller is a process-wide singleton. Reset it to a known state + // at the start of every test so ordering between tests does not matter. + SentryTurboModulePerfController::instance().setSink(nullptr); + SentryTurboModulePerfController::instance().setEnabled(false); +} + +- (void)tearDown +{ + SentryTurboModulePerfController::instance().setSink(nullptr); + SentryTurboModulePerfController::instance().setEnabled(false); +} + +- (void)testEnabledFlagDefaultsToFalse +{ + // After setUp clears it, the controller must report disabled. This is + // the load-time default we ship and the contract the JS option toggles + // against. + XCTAssertFalse(SentryTurboModulePerfController::instance().isEnabled()); +} + +- (void)testSetEnabledTogglesIsEnabled +{ + SentryTurboModulePerfController::instance().setEnabled(true); + XCTAssertTrue(SentryTurboModulePerfController::instance().isEnabled()); + + SentryTurboModulePerfController::instance().setEnabled(false); + XCTAssertFalse(SentryTurboModulePerfController::instance().isEnabled()); +} + +- (void)testCEntryPointMatchesSetEnabled +{ + // The Java/ObjC platform glue calls into the controller via the C entry + // point. Verify both paths agree on the underlying flag. + Sentry_SetTurboModuleTrackingEnabled(1); + XCTAssertTrue(SentryTurboModulePerfController::instance().isEnabled()); + + Sentry_SetTurboModuleTrackingEnabled(0); + XCTAssertFalse(SentryTurboModulePerfController::instance().isEnabled()); +} + +- (void)testSetSinkRoundTrip +{ + auto recording = std::make_shared(); + SentryTurboModulePerfController::instance().setSink(recording); + + auto retrieved = SentryTurboModulePerfController::instance().sink(); + XCTAssertEqual(retrieved.get(), recording.get(), + @"sink() must return the same shared_ptr that was just installed"); + + SentryTurboModulePerfController::instance().setSink(nullptr); + XCTAssertEqual(SentryTurboModulePerfController::instance().sink().get(), nullptr, + @"passing nullptr must detach the sink"); +} + +- (void)testInstallIsIdempotent +{ + // Calling install() more than once must not crash, must not replace the + // logger (RN's `enableLogging` would happily accept a second logger and + // we would lose continuity), and must not deadlock. + Sentry_InstallTurboModulePerfLogger(); + Sentry_InstallTurboModulePerfLogger(); + Sentry_InstallTurboModulePerfLogger(); + // Reaching this point with no crash is the contract. + XCTAssertTrue(true); +} + +@end + +// NOTE: end-to-end forwarding (RN's `TurboModulePerfLogger::moduleCreateStart` +// arriving at the installed sink) is not unit-tested here. That path goes +// through `+load` static initialisation timing and a process-wide singleton +// that other tests in this bundle may have already touched; verifying it in +// isolation requires hooks we deliberately did not add to the production +// surface. The follow-up sink PRs exercise the path via integration tests. + diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java index b6fa8d1b99..a8e0303586 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java @@ -1,6 +1,7 @@ package io.sentry.react; import android.util.Log; +import org.jetbrains.annotations.TestOnly; /** * Thin Java façade over the native runtime flag installed by @@ -48,4 +49,14 @@ public static void setEnabled(boolean enabled) { } private static native void nativeSetEnabled(boolean enabled); + + @TestOnly + public static boolean isNativeUnavailableForTests() { + return nativeUnavailable; + } + + @TestOnly + public static void resetNativeUnavailableForTests() { + nativeUnavailable = false; + } } From be968f377e808493e88fb78e87aea4f63fc8e8e9 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 10:53:17 +0200 Subject: [PATCH 03/22] fix(turbomodule): Defer logger install until enableTurboModuleTracking flips on Address two related medium findings on #6307: - Warden: `enableLogging` runs from `+load` / `JNI_OnLoad` regardless of the runtime flag, unconditionally evicting any pre-existing `NativeModulePerfLogger` (Metro, other SDKs, host-app instrumentation). - Cursor: when `enableTurboModuleTracking: true`, callbacks between load time and `initNativeSdk` are dropped by the `enabled_=false` fast-path anyway, so the eager install was not actually delivering on its 'never miss early events' promise \u2014 just on its side effects. The fix is a single one-way ratchet: `setEnabled(true)` lazily calls `install()` on the first transition, and the typed setter doubles as the public lifecycle hook. The `+load` installer class on iOS and the `JNI_OnLoad` install on Android are gone; the C `Sentry_InstallTurboModulePerfLogger` entry stays for hosts that want to claim the perf-logger slot eagerly via their own native code, but it is no longer wired into our load hooks. Header / JSDoc updated to describe the new contract. Also fix two adjacent issues flagged on the same PR: - Sentry HIGH (build.gradle): two sibling `buildFeatures { ... }` blocks under the same Android scope replace rather than merge, so `prefab = true` was clobbering `buildConfig = true` on AGP 8+. Merge into a single conditional block. - Lint: ran `yarn java:format fix`, `yarn fix:clang`, and switched `RNSentryTurboModulePerfTracker.nativeUnavailable` from `volatile` to `AtomicBoolean` to satisfy the project-wide PMD `AvoidUsingVolatile` rule. Removed a Kotlin `no-consecutive-comments` violation from the Robolectric note above the tracker test. Test updates: - iOS: add `testSetEnabledFalseDoesNotInstall` and `testSetEnabledTrueIsLazyInstallAndSticky` to lock down the lazy install ratchet. Existing `testInstallIsIdempotent` still covers explicit-install callers. - Android: tracker tests unchanged in behaviour; only the test-only `isNativeUnavailableForTests` / `resetNativeUnavailableForTests` helpers were updated to go through the new `AtomicBoolean`. --- .../RNSentryTurboModulePerfTrackerTest.kt | 7 +- .../RNSentryTurboModulePerfControllerTests.mm | 266 +++++++++++--- packages/core/android/build.gradle | 27 +- .../react/RNSentryTurboModulePerfTracker.java | 27 +- packages/core/android/src/main/jni/OnLoad.cpp | 34 +- .../core/cpp/SentryTurboModulePerfLogger.cpp | 340 ++++++++++-------- .../core/cpp/SentryTurboModulePerfLogger.h | 105 +++--- packages/core/cpp/SentryTurboModulePerfSink.h | 114 +++--- packages/core/ios/RNSentry.mm | 24 +- 9 files changed, 569 insertions(+), 375 deletions(-) diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt index 8cdd170fb7..143e960dd3 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt @@ -16,10 +16,11 @@ import org.robolectric.RobolectricTestRunner * `libsentry-tm-perf-logger.so`, so any call into the native method must throw * `UnsatisfiedLinkError`. The tracker is expected to swallow that error and * flip an internal latch so subsequent calls short-circuit without retrying. + * + * Uses Robolectric so the `android.util.Log` call inside the tracker's `catch` + * branch resolves to a real implementation instead of the default-not-mocked + * stub the bare JUnit4 runner exposes. */ -// Robolectric runner so the `android.util.Log` call inside the tracker's -// `catch` branch resolves to a real implementation instead of the -// default-not-mocked stub the bare JUnit4 runner exposes. @RunWith(RobolectricTestRunner::class) class RNSentryTurboModulePerfTrackerTest { @Before diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm index 46fe444fee..666f367ac4 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm @@ -28,61 +28,187 @@ /// counters here — the goal is to verify that the controller actually routes /// events to the installed sink, not to exhaustively cover every RN callback. class RecordingSink : public ISentryTurboModulePerfSink { - public: - std::atomic moduleCreateStartCalls{0}; - std::atomic syncMethodCallStartCalls{0}; - - void moduleDataCreateStart(const char* /*moduleName*/, int32_t /*id*/) override {} - void moduleDataCreateEnd(const char* /*moduleName*/, int32_t /*id*/) override {} - void moduleCreateStart(const char* /*moduleName*/, int32_t /*id*/) override { - moduleCreateStartCalls.fetch_add(1, std::memory_order_relaxed); - } - void moduleCreateCacheHit(const char* /*moduleName*/, int32_t /*id*/) override {} - void moduleCreateConstructStart(const char* /*moduleName*/, int32_t /*id*/) override {} - void moduleCreateConstructEnd(const char* /*moduleName*/, int32_t /*id*/) override {} - void moduleCreateSetUpStart(const char* /*moduleName*/, int32_t /*id*/) override {} - void moduleCreateSetUpEnd(const char* /*moduleName*/, int32_t /*id*/) override {} - void moduleCreateEnd(const char* /*moduleName*/, int32_t /*id*/) override {} - void moduleCreateFail(const char* /*moduleName*/, int32_t /*id*/) override {} - - void moduleJSRequireBeginningStart(const char* /*moduleName*/) override {} - void moduleJSRequireBeginningCacheHit(const char* /*moduleName*/) override {} - void moduleJSRequireBeginningEnd(const char* /*moduleName*/) override {} - void moduleJSRequireBeginningFail(const char* /*moduleName*/) override {} - void moduleJSRequireEndingStart(const char* /*moduleName*/) override {} - void moduleJSRequireEndingEnd(const char* /*moduleName*/) override {} - void moduleJSRequireEndingFail(const char* /*moduleName*/) override {} - - void syncMethodCallStart(const char* /*moduleName*/, const char* /*methodName*/) override { - syncMethodCallStartCalls.fetch_add(1, std::memory_order_relaxed); - } - void syncMethodCallArgConversionStart(const char* /*moduleName*/, const char* /*methodName*/) override {} - void syncMethodCallArgConversionEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} - void syncMethodCallExecutionStart(const char* /*moduleName*/, const char* /*methodName*/) override {} - void syncMethodCallExecutionEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} - void syncMethodCallReturnConversionStart(const char* /*moduleName*/, const char* /*methodName*/) override {} - void syncMethodCallReturnConversionEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} - void syncMethodCallEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} - void syncMethodCallFail(const char* /*moduleName*/, const char* /*methodName*/) override {} - - void asyncMethodCallStart(const char* /*moduleName*/, const char* /*methodName*/) override {} - void asyncMethodCallArgConversionStart(const char* /*moduleName*/, const char* /*methodName*/) override {} - void asyncMethodCallArgConversionEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} - void asyncMethodCallDispatch(const char* /*moduleName*/, const char* /*methodName*/) override {} - void asyncMethodCallEnd(const char* /*moduleName*/, const char* /*methodName*/) override {} - void asyncMethodCallFail(const char* /*moduleName*/, const char* /*methodName*/) override {} - - void asyncMethodCallBatchPreprocessStart() override {} - void asyncMethodCallBatchPreprocessEnd(int /*batchSize*/) override {} - - void asyncMethodCallExecutionStart(const char* /*moduleName*/, const char* /*methodName*/, int32_t /*id*/) override {} - void asyncMethodCallExecutionArgConversionStart(const char* /*moduleName*/, const char* /*methodName*/, int32_t /*id*/) override {} - void asyncMethodCallExecutionArgConversionEnd(const char* /*moduleName*/, const char* /*methodName*/, int32_t /*id*/) override {} - void asyncMethodCallExecutionEnd(const char* /*moduleName*/, const char* /*methodName*/, int32_t /*id*/) override {} - void asyncMethodCallExecutionFail(const char* /*moduleName*/, const char* /*methodName*/, int32_t /*id*/) override {} +public: + std::atomic moduleCreateStartCalls { 0 }; + std::atomic syncMethodCallStartCalls { 0 }; + + void + moduleDataCreateStart(const char * /*moduleName*/, int32_t /*id*/) override + { + } + void + moduleDataCreateEnd(const char * /*moduleName*/, int32_t /*id*/) override + { + } + void + moduleCreateStart(const char * /*moduleName*/, int32_t /*id*/) override + { + moduleCreateStartCalls.fetch_add(1, std::memory_order_relaxed); + } + void + moduleCreateCacheHit(const char * /*moduleName*/, int32_t /*id*/) override + { + } + void + moduleCreateConstructStart(const char * /*moduleName*/, int32_t /*id*/) override + { + } + void + moduleCreateConstructEnd(const char * /*moduleName*/, int32_t /*id*/) override + { + } + void + moduleCreateSetUpStart(const char * /*moduleName*/, int32_t /*id*/) override + { + } + void + moduleCreateSetUpEnd(const char * /*moduleName*/, int32_t /*id*/) override + { + } + void + moduleCreateEnd(const char * /*moduleName*/, int32_t /*id*/) override + { + } + void + moduleCreateFail(const char * /*moduleName*/, int32_t /*id*/) override + { + } + + void + moduleJSRequireBeginningStart(const char * /*moduleName*/) override + { + } + void + moduleJSRequireBeginningCacheHit(const char * /*moduleName*/) override + { + } + void + moduleJSRequireBeginningEnd(const char * /*moduleName*/) override + { + } + void + moduleJSRequireBeginningFail(const char * /*moduleName*/) override + { + } + void + moduleJSRequireEndingStart(const char * /*moduleName*/) override + { + } + void + moduleJSRequireEndingEnd(const char * /*moduleName*/) override + { + } + void + moduleJSRequireEndingFail(const char * /*moduleName*/) override + { + } + + void + syncMethodCallStart(const char * /*moduleName*/, const char * /*methodName*/) override + { + syncMethodCallStartCalls.fetch_add(1, std::memory_order_relaxed); + } + void + syncMethodCallArgConversionStart( + const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + syncMethodCallArgConversionEnd( + const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + syncMethodCallExecutionStart(const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + syncMethodCallExecutionEnd(const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + syncMethodCallReturnConversionStart( + const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + syncMethodCallReturnConversionEnd( + const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + syncMethodCallEnd(const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + syncMethodCallFail(const char * /*moduleName*/, const char * /*methodName*/) override + { + } + + void + asyncMethodCallStart(const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + asyncMethodCallArgConversionStart( + const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + asyncMethodCallArgConversionEnd( + const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + asyncMethodCallDispatch(const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + asyncMethodCallEnd(const char * /*moduleName*/, const char * /*methodName*/) override + { + } + void + asyncMethodCallFail(const char * /*moduleName*/, const char * /*methodName*/) override + { + } + + void + asyncMethodCallBatchPreprocessStart() override + { + } + void + asyncMethodCallBatchPreprocessEnd(int /*batchSize*/) override + { + } + + void + asyncMethodCallExecutionStart( + const char * /*moduleName*/, const char * /*methodName*/, int32_t /*id*/) override + { + } + void + asyncMethodCallExecutionArgConversionStart( + const char * /*moduleName*/, const char * /*methodName*/, int32_t /*id*/) override + { + } + void + asyncMethodCallExecutionArgConversionEnd( + const char * /*moduleName*/, const char * /*methodName*/, int32_t /*id*/) override + { + } + void + asyncMethodCallExecutionEnd( + const char * /*moduleName*/, const char * /*methodName*/, int32_t /*id*/) override + { + } + void + asyncMethodCallExecutionFail( + const char * /*moduleName*/, const char * /*methodName*/, int32_t /*id*/) override + { + } }; -} // namespace +} // namespace @interface RNSentryTurboModulePerfControllerTests : XCTestCase @end @@ -157,6 +283,39 @@ - (void)testInstallIsIdempotent XCTAssertTrue(true); } +- (void)testSetEnabledFalseDoesNotInstall +{ + // The first part of the lazy-install contract: while tracking is off we + // never claim the perf-logger slot from React Native. Calling + // `setEnabled(false)` from a freshly reset controller must keep the + // enabled flag at `false` and must not have any other observable effect. + // (Direct introspection of "is the logger currently registered with RN?" + // is not exposed by `facebook::react::TurboModulePerfLogger`; we cover + // this contractually by verifying the flag and relying on the explicit + // install path for the install-side coverage.) + SentryTurboModulePerfController::instance().setEnabled(false); + XCTAssertFalse(SentryTurboModulePerfController::instance().isEnabled()); +} + +- (void)testSetEnabledTrueIsLazyInstallAndSticky +{ + // The second part of the lazy-install contract: `setEnabled(true)` + // installs the logger and any further toggle keeps it installed (we never + // "un-install" by handing RN back its previous logger — the perf-logger + // API does not support that, so a one-way ratchet is the only correct + // model). What we verify here is that the toggle reaches the controller + // safely from both the typed setter and the C entry point, and that the + // enabled flag tracks the latest call regardless of install state. + SentryTurboModulePerfController::instance().setEnabled(true); + XCTAssertTrue(SentryTurboModulePerfController::instance().isEnabled()); + + SentryTurboModulePerfController::instance().setEnabled(false); + XCTAssertFalse(SentryTurboModulePerfController::instance().isEnabled()); + + Sentry_SetTurboModuleTrackingEnabled(1); + XCTAssertTrue(SentryTurboModulePerfController::instance().isEnabled()); +} + @end // NOTE: end-to-end forwarding (RN's `TurboModulePerfLogger::moduleCreateStart` @@ -165,4 +324,3 @@ - (void)testInstallIsIdempotent // that other tests in this bundle may have already touched; verifying it in // isolation requires hooks we deliberately did not add to the production // surface. The follow-up sink PRs exercise the path via integration tests. - diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 291a15b0bc..dd9c911469 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -39,22 +39,31 @@ android { namespace = "io.sentry.react" } + // A single `buildFeatures { ... }` block per Android extension scope: the + // Gradle DSL replaces (not merges) prior blocks, so splitting `buildConfig` + // and `prefab` into two siblings would silently drop the first one. See + // https://issuetracker.google.com/issues/247711031 for the corresponding + // AGP gotcha. def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION - if (agpVersion.tokenize('.')[0].toInteger() >= 8) { + def needsBuildConfig = agpVersion.tokenize('.')[0].toInteger() >= 8 + def needsPrefab = isNewArchitectureEnabled() + if (needsBuildConfig || needsPrefab) { buildFeatures { - buildConfig = true + if (needsBuildConfig) { + buildConfig = true + } + if (needsPrefab) { + // `libsentry-tm-perf-logger.so` links against React Native's + // `reactnative` prefab, which only ships when the New + // Architecture is enabled. + prefab true + } } } - // `libsentry-tm-perf-logger.so` installs Sentry's TurboModule perf logger - // at JNI load time. It depends on React Native's `reactnative` prefab - // (which only ships when the New Architecture is enabled), so we wire - // CMake + prefab in only under New Arch. On Old Arch the .so is never + // CMake is also gated on New Architecture: on Old Arch the .so is never // built and `RNSentryPackage` catches the missing-library error. if (isNewArchitectureEnabled()) { - buildFeatures { - prefab true - } externalNativeBuild { cmake { path "CMakeLists.txt" diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java index a8e0303586..9b2fbdf480 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java @@ -1,20 +1,20 @@ package io.sentry.react; import android.util.Log; +import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.TestOnly; /** - * Thin Java façade over the native runtime flag installed by - * {@code libsentry-tm-perf-logger.so}. + * Thin Java façade over the native runtime flag installed by {@code libsentry-tm-perf-logger.so}. * *

The native library is only built when the consuming app is using React Native's New * Architecture (see {@code CMakeLists.txt} and {@code build.gradle}). On Old Architecture the - * underlying {@code .so} is not packaged, so the first call to {@link #setEnabled(boolean)} hits - * an {@link UnsatisfiedLinkError} which we swallow — TurboModule perf tracking is a no-op there. + * underlying {@code .so} is not packaged, so the first call to {@link #setEnabled(boolean)} hits an + * {@link UnsatisfiedLinkError} which we swallow — TurboModule perf tracking is a no-op there. * - *

We deliberately keep the linkage check lazy (try-catch on first invocation) instead of - * probing at class load time so that the SDK's {@code initNativeSdk} call path stays the single - * source of truth for whether tracking is on. + *

We deliberately keep the linkage check lazy (try-catch on first invocation) instead of probing + * at class load time so that the SDK's {@code initNativeSdk} call path stays the single source of + * truth for whether tracking is on. */ public final class RNSentryTurboModulePerfTracker { @@ -23,9 +23,10 @@ public final class RNSentryTurboModulePerfTracker { /** * Remembers whether we have already discovered the native symbol to be missing. After the first * UnsatisfiedLinkError we stop trying — there is no scenario where the link suddenly succeeds - * within the same process lifetime. + * within the same process lifetime. Using `AtomicBoolean` instead of `volatile` to satisfy the + * project-wide PMD rule (`AvoidUsingVolatile`). */ - private static volatile boolean nativeUnavailable = false; + private static final AtomicBoolean nativeUnavailable = new AtomicBoolean(false); private RNSentryTurboModulePerfTracker() {} @@ -35,13 +36,13 @@ private RNSentryTurboModulePerfTracker() {} * {@code true} the callback is forwarded to whichever sink is currently installed in C++. */ public static void setEnabled(boolean enabled) { - if (nativeUnavailable) { + if (nativeUnavailable.get()) { return; } try { nativeSetEnabled(enabled); } catch (UnsatisfiedLinkError e) { - nativeUnavailable = true; + nativeUnavailable.set(true); Log.i( TAG, "TurboModule perf-logger native symbol not found; tracking disabled: " + e.getMessage()); @@ -52,11 +53,11 @@ public static void setEnabled(boolean enabled) { @TestOnly public static boolean isNativeUnavailableForTests() { - return nativeUnavailable; + return nativeUnavailable.get(); } @TestOnly public static void resetNativeUnavailableForTests() { - nativeUnavailable = false; + nativeUnavailable.set(false); } } diff --git a/packages/core/android/src/main/jni/OnLoad.cpp b/packages/core/android/src/main/jni/OnLoad.cpp index d65018a5c8..218b79c21b 100644 --- a/packages/core/android/src/main/jni/OnLoad.cpp +++ b/packages/core/android/src/main/jni/OnLoad.cpp @@ -1,37 +1,27 @@ // Copyright (c) Sentry. All rights reserved. // -// JNI entry point for the Sentry TurboModule perf-logger shared library. +// JNI bridge for the Sentry TurboModule perf-logger shared library. // -// This shared library (`libsentry-tm-perf-logger.so`) is dedicated to wiring -// up Sentry's `facebook::react::NativeModulePerfLogger` so the SDK observes -// every TurboModule lifecycle event without forcing host apps to modify -// their own `OnLoad.cpp`. +// This shared library (`libsentry-tm-perf-logger.so`) hosts the C++ side of +// the perf-logger controller plus the JNI symbol the JVM tracker calls into. // -// The library is loaded from `RNSentryPackage`'s static initializer via -// `System.loadLibrary("sentry-tm-perf-logger")`, which fires before any -// TurboModule is instantiated by React Native. Inside `JNI_OnLoad` we install -// the perf logger so the very first `moduleDataCreateStart` we see is the -// one for the very first TurboModule the host registers. +// We deliberately do NOT install the perf logger from `JNI_OnLoad`: the +// install evicts any pre-existing `NativeModulePerfLogger` (Metro, another +// SDK, host-app instrumentation) and that side effect should only happen +// when the user has explicitly opted in via `enableTurboModuleTracking`. +// The lazy install path lives inside +// `SentryTurboModulePerfController::setEnabled(true)`. #include #include "../../../../cpp/SentryTurboModulePerfLogger.h" -extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* /*vm*/, void* /*reserved*/) { - // Install the perf logger as soon as the library is loaded. The - // controller is reachable from Java via the implicit-named JNI method - // declared below; we do not register methods explicitly here. - Sentry_InstallTurboModulePerfLogger(); - return JNI_VERSION_1_6; -} - /// Java-callable runtime toggle for the perf-logger sink. Linked into Java /// by name (`Java_io_sentry_react_RNSentryTurboModulePerfTracker_nativeSetEnabled`) /// so we do not need an explicit `RegisterNatives` table. extern "C" JNIEXPORT void JNICALL Java_io_sentry_react_RNSentryTurboModulePerfTracker_nativeSetEnabled( - JNIEnv* /*env*/, - jclass /*clazz*/, - jboolean enabled) { - Sentry_SetTurboModuleTrackingEnabled(enabled ? 1 : 0); + JNIEnv * /*env*/, jclass /*clazz*/, jboolean enabled) +{ + Sentry_SetTurboModuleTrackingEnabled(enabled ? 1 : 0); } diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.cpp b/packages/core/cpp/SentryTurboModulePerfLogger.cpp index c5e5bb5ed2..c22ce351ec 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.cpp +++ b/packages/core/cpp/SentryTurboModulePerfLogger.cpp @@ -9,14 +9,14 @@ #include "SentryTurboModulePerfLogger.h" #if defined(RCT_NEW_ARCH_ENABLED) || defined(__ANDROID__) -# define SENTRY_TM_PERF_LOGGER_AVAILABLE 1 +# define SENTRY_TM_PERF_LOGGER_AVAILABLE 1 #else -# define SENTRY_TM_PERF_LOGGER_AVAILABLE 0 +# define SENTRY_TM_PERF_LOGGER_AVAILABLE 0 #endif #if SENTRY_TM_PERF_LOGGER_AVAILABLE -# include -# include +# include +# include #endif #include @@ -29,170 +29,210 @@ namespace sentry::reactnative { namespace { -/// Concrete `NativeModulePerfLogger` subclass we hand to React Native. It owns -/// no state of its own — every callback goes through -/// `SentryTurboModulePerfController` so the sink and the runtime flag can be -/// swapped without re-installing the logger. -class ForwardingLogger final : public facebook::react::NativeModulePerfLogger { - public: - // The macro below lets us keep this file readable. Without it we'd have - // ~30 near-identical 5-line method bodies; with it the surface fits on one - // screen and any divergence between RN's API and ours surfaces as a compile - // error rather than a silent drop. -#define SENTRY_FORWARD0(name) \ - void name() override { \ - auto& c = SentryTurboModulePerfController::instance(); \ - if (!c.isEnabled()) { \ - return; \ - } \ - if (auto sink = c.sink()) { \ - sink->name(); \ - } \ - } - -#define SENTRY_FORWARD1(name, arg1Type, arg1Name) \ - void name(arg1Type arg1Name) override { \ - auto& c = SentryTurboModulePerfController::instance(); \ - if (!c.isEnabled()) { \ - return; \ - } \ - if (auto sink = c.sink()) { \ - sink->name(arg1Name); \ - } \ - } - -#define SENTRY_FORWARD2(name, t1, n1, t2, n2) \ - void name(t1 n1, t2 n2) override { \ - auto& c = SentryTurboModulePerfController::instance(); \ - if (!c.isEnabled()) { \ - return; \ - } \ - if (auto sink = c.sink()) { \ - sink->name(n1, n2); \ - } \ - } - -#define SENTRY_FORWARD3(name, t1, n1, t2, n2, t3, n3) \ - void name(t1 n1, t2 n2, t3 n3) override { \ - auto& c = SentryTurboModulePerfController::instance(); \ - if (!c.isEnabled()) { \ - return; \ - } \ - if (auto sink = c.sink()) { \ - sink->name(n1, n2, n3); \ - } \ - } - - // Module data / create - SENTRY_FORWARD2(moduleDataCreateStart, const char*, moduleName, int32_t, id) - SENTRY_FORWARD2(moduleDataCreateEnd, const char*, moduleName, int32_t, id) - SENTRY_FORWARD2(moduleCreateStart, const char*, moduleName, int32_t, id) - SENTRY_FORWARD2(moduleCreateCacheHit, const char*, moduleName, int32_t, id) - SENTRY_FORWARD2(moduleCreateConstructStart, const char*, moduleName, int32_t, id) - SENTRY_FORWARD2(moduleCreateConstructEnd, const char*, moduleName, int32_t, id) - SENTRY_FORWARD2(moduleCreateSetUpStart, const char*, moduleName, int32_t, id) - SENTRY_FORWARD2(moduleCreateSetUpEnd, const char*, moduleName, int32_t, id) - SENTRY_FORWARD2(moduleCreateEnd, const char*, moduleName, int32_t, id) - SENTRY_FORWARD2(moduleCreateFail, const char*, moduleName, int32_t, id) - - // JS require timings - SENTRY_FORWARD1(moduleJSRequireBeginningStart, const char*, moduleName) - SENTRY_FORWARD1(moduleJSRequireBeginningCacheHit, const char*, moduleName) - SENTRY_FORWARD1(moduleJSRequireBeginningEnd, const char*, moduleName) - SENTRY_FORWARD1(moduleJSRequireBeginningFail, const char*, moduleName) - SENTRY_FORWARD1(moduleJSRequireEndingStart, const char*, moduleName) - SENTRY_FORWARD1(moduleJSRequireEndingEnd, const char*, moduleName) - SENTRY_FORWARD1(moduleJSRequireEndingFail, const char*, moduleName) - - // Sync method calls - SENTRY_FORWARD2(syncMethodCallStart, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(syncMethodCallArgConversionStart, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(syncMethodCallArgConversionEnd, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(syncMethodCallExecutionStart, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(syncMethodCallExecutionEnd, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(syncMethodCallReturnConversionStart, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(syncMethodCallReturnConversionEnd, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(syncMethodCallEnd, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(syncMethodCallFail, const char*, moduleName, const char*, methodName) - - // Async method calls (call half) - SENTRY_FORWARD2(asyncMethodCallStart, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(asyncMethodCallArgConversionStart, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(asyncMethodCallArgConversionEnd, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(asyncMethodCallDispatch, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(asyncMethodCallEnd, const char*, moduleName, const char*, methodName) - SENTRY_FORWARD2(asyncMethodCallFail, const char*, moduleName, const char*, methodName) - - // Async batch preprocess - SENTRY_FORWARD0(asyncMethodCallBatchPreprocessStart) - SENTRY_FORWARD1(asyncMethodCallBatchPreprocessEnd, int, batchSize) - - // Async method calls (execution half) - SENTRY_FORWARD3(asyncMethodCallExecutionStart, const char*, moduleName, const char*, methodName, int32_t, id) - SENTRY_FORWARD3(asyncMethodCallExecutionArgConversionStart, const char*, moduleName, const char*, methodName, int32_t, id) - SENTRY_FORWARD3(asyncMethodCallExecutionArgConversionEnd, const char*, moduleName, const char*, methodName, int32_t, id) - SENTRY_FORWARD3(asyncMethodCallExecutionEnd, const char*, moduleName, const char*, methodName, int32_t, id) - SENTRY_FORWARD3(asyncMethodCallExecutionFail, const char*, moduleName, const char*, methodName, int32_t, id) - -#undef SENTRY_FORWARD0 -#undef SENTRY_FORWARD1 -#undef SENTRY_FORWARD2 -#undef SENTRY_FORWARD3 -}; - -} // namespace - -#endif // SENTRY_TM_PERF_LOGGER_AVAILABLE - -SentryTurboModulePerfController& SentryTurboModulePerfController::instance() noexcept { - // Function-local static — guaranteed thread-safe initialisation since C++11, - // and avoids the static-initialisation-order fiasco that bites global singletons - // hand-rolled in this kind of native-bridge code. - static SentryTurboModulePerfController controller; - return controller; + /// Concrete `NativeModulePerfLogger` subclass we hand to React Native. It owns + /// no state of its own — every callback goes through + /// `SentryTurboModulePerfController` so the sink and the runtime flag can be + /// swapped without re-installing the logger. + class ForwardingLogger final : public facebook::react::NativeModulePerfLogger { + public: + // The macro below lets us keep this file readable. Without it we'd have + // ~30 near-identical 5-line method bodies; with it the surface fits on one + // screen and any divergence between RN's API and ours surfaces as a compile + // error rather than a silent drop. +# define SENTRY_FORWARD0(name) \ + void name() override \ + { \ + auto &c = SentryTurboModulePerfController::instance(); \ + if (!c.isEnabled()) { \ + return; \ + } \ + if (auto sink = c.sink()) { \ + sink->name(); \ + } \ + } + +# define SENTRY_FORWARD1(name, arg1Type, arg1Name) \ + void name(arg1Type arg1Name) override \ + { \ + auto &c = SentryTurboModulePerfController::instance(); \ + if (!c.isEnabled()) { \ + return; \ + } \ + if (auto sink = c.sink()) { \ + sink->name(arg1Name); \ + } \ + } + +# define SENTRY_FORWARD2(name, t1, n1, t2, n2) \ + void name(t1 n1, t2 n2) override \ + { \ + auto &c = SentryTurboModulePerfController::instance(); \ + if (!c.isEnabled()) { \ + return; \ + } \ + if (auto sink = c.sink()) { \ + sink->name(n1, n2); \ + } \ + } + +# define SENTRY_FORWARD3(name, t1, n1, t2, n2, t3, n3) \ + void name(t1 n1, t2 n2, t3 n3) override \ + { \ + auto &c = SentryTurboModulePerfController::instance(); \ + if (!c.isEnabled()) { \ + return; \ + } \ + if (auto sink = c.sink()) { \ + sink->name(n1, n2, n3); \ + } \ + } + + // Module data / create + SENTRY_FORWARD2(moduleDataCreateStart, const char *, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleDataCreateEnd, const char *, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateStart, const char *, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateCacheHit, const char *, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateConstructStart, const char *, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateConstructEnd, const char *, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateSetUpStart, const char *, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateSetUpEnd, const char *, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateEnd, const char *, moduleName, int32_t, id) + SENTRY_FORWARD2(moduleCreateFail, const char *, moduleName, int32_t, id) + + // JS require timings + SENTRY_FORWARD1(moduleJSRequireBeginningStart, const char *, moduleName) + SENTRY_FORWARD1(moduleJSRequireBeginningCacheHit, const char *, moduleName) + SENTRY_FORWARD1(moduleJSRequireBeginningEnd, const char *, moduleName) + SENTRY_FORWARD1(moduleJSRequireBeginningFail, const char *, moduleName) + SENTRY_FORWARD1(moduleJSRequireEndingStart, const char *, moduleName) + SENTRY_FORWARD1(moduleJSRequireEndingEnd, const char *, moduleName) + SENTRY_FORWARD1(moduleJSRequireEndingFail, const char *, moduleName) + + // Sync method calls + SENTRY_FORWARD2(syncMethodCallStart, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2( + syncMethodCallArgConversionStart, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2( + syncMethodCallArgConversionEnd, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2( + syncMethodCallExecutionStart, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2( + syncMethodCallExecutionEnd, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2( + syncMethodCallReturnConversionStart, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2( + syncMethodCallReturnConversionEnd, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2(syncMethodCallEnd, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2(syncMethodCallFail, const char *, moduleName, const char *, methodName) + + // Async method calls (call half) + SENTRY_FORWARD2(asyncMethodCallStart, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2( + asyncMethodCallArgConversionStart, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2( + asyncMethodCallArgConversionEnd, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2(asyncMethodCallDispatch, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2(asyncMethodCallEnd, const char *, moduleName, const char *, methodName) + SENTRY_FORWARD2(asyncMethodCallFail, const char *, moduleName, const char *, methodName) + + // Async batch preprocess + SENTRY_FORWARD0(asyncMethodCallBatchPreprocessStart) + SENTRY_FORWARD1(asyncMethodCallBatchPreprocessEnd, int, batchSize) + + // Async method calls (execution half) + SENTRY_FORWARD3(asyncMethodCallExecutionStart, const char *, moduleName, const char *, + methodName, int32_t, id) + SENTRY_FORWARD3(asyncMethodCallExecutionArgConversionStart, const char *, moduleName, + const char *, methodName, int32_t, id) + SENTRY_FORWARD3(asyncMethodCallExecutionArgConversionEnd, const char *, moduleName, + const char *, methodName, int32_t, id) + SENTRY_FORWARD3(asyncMethodCallExecutionEnd, const char *, moduleName, const char *, + methodName, int32_t, id) + SENTRY_FORWARD3(asyncMethodCallExecutionFail, const char *, moduleName, const char *, + methodName, int32_t, id) + +# undef SENTRY_FORWARD0 +# undef SENTRY_FORWARD1 +# undef SENTRY_FORWARD2 +# undef SENTRY_FORWARD3 + }; + +} // namespace + +#endif // SENTRY_TM_PERF_LOGGER_AVAILABLE + +SentryTurboModulePerfController & +SentryTurboModulePerfController::instance() noexcept +{ + // Function-local static — guaranteed thread-safe initialisation since C++11, + // and avoids the static-initialisation-order fiasco that bites global singletons + // hand-rolled in this kind of native-bridge code. + static SentryTurboModulePerfController controller; + return controller; } -void SentryTurboModulePerfController::install() noexcept { +void +SentryTurboModulePerfController::install() noexcept +{ #if SENTRY_TM_PERF_LOGGER_AVAILABLE - // `compare_exchange_strong` makes the install idempotent across competing - // threads: only the first caller transitions `installed_` from `false` to - // `true`, and only that caller hands the logger off to React Native. - bool expected = false; - if (!installed_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { - return; - } - facebook::react::TurboModulePerfLogger::enableLogging(std::make_unique()); + // `compare_exchange_strong` makes the install idempotent across competing + // threads: only the first caller transitions `installed_` from `false` to + // `true`, and only that caller hands the logger off to React Native. + bool expected = false; + if (!installed_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { + return; + } + facebook::react::TurboModulePerfLogger::enableLogging(std::make_unique()); #endif } -void SentryTurboModulePerfController::setSink(std::shared_ptr sink) noexcept { - std::lock_guard lock(sink_mutex_); - sink_ = std::move(sink); +void +SentryTurboModulePerfController::setEnabled(bool enabled) noexcept +{ + // Enabling tracking lazily installs the logger. This avoids evicting any + // pre-existing `NativeModulePerfLogger` (Metro, other SDKs, host-app + // instrumentation) when the user has not opted in to TurboModule tracking, + // and matches the cost model promised by the JSDoc default of `false`. + if (enabled) { + install(); + } + enabled_.store(enabled, std::memory_order_release); } -std::shared_ptr SentryTurboModulePerfController::sink() const noexcept { - std::lock_guard lock(sink_mutex_); - return sink_; +void +SentryTurboModulePerfController::setSink(std::shared_ptr sink) noexcept +{ + std::lock_guard lock(sink_mutex_); + sink_ = std::move(sink); } -void SentryTurboModulePerfController::setEnabled(bool enabled) noexcept { - enabled_.store(enabled, std::memory_order_release); +std::shared_ptr +SentryTurboModulePerfController::sink() const noexcept +{ + std::lock_guard lock(sink_mutex_); + return sink_; } -bool SentryTurboModulePerfController::isEnabled() const noexcept { - return enabled_.load(std::memory_order_acquire); +bool +SentryTurboModulePerfController::isEnabled() const noexcept +{ + return enabled_.load(std::memory_order_acquire); } -} // namespace sentry::reactnative +} // namespace sentry::reactnative extern "C" { -void Sentry_InstallTurboModulePerfLogger(void) { - sentry::reactnative::SentryTurboModulePerfController::instance().install(); +void +Sentry_InstallTurboModulePerfLogger(void) +{ + sentry::reactnative::SentryTurboModulePerfController::instance().install(); } -void Sentry_SetTurboModuleTrackingEnabled(int enabled) { - sentry::reactnative::SentryTurboModulePerfController::instance().setEnabled(enabled != 0); +void +Sentry_SetTurboModuleTrackingEnabled(int enabled) +{ + sentry::reactnative::SentryTurboModulePerfController::instance().setEnabled(enabled != 0); } -} // extern "C" +} // extern "C" diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.h b/packages/core/cpp/SentryTurboModulePerfLogger.h index 3ccf558c75..4bca219e00 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.h +++ b/packages/core/cpp/SentryTurboModulePerfLogger.h @@ -43,66 +43,71 @@ class SentryTurboModulePerfLogger; /// as a C-linkage symbol so the JNI side can call it from `JNI_OnLoad` /// without dragging the C++ ABI through the JNI boundary). class SentryTurboModulePerfController { - public: - /// Returns the process-wide controller instance. The controller owns the - /// installed logger and the active sink. - static SentryTurboModulePerfController& instance() noexcept; - - /// Idempotent install. The first call constructs a `SentryTurboModulePerfLogger` - /// and hands it to RN via `facebook::react::TurboModulePerfLogger::enableLogging`. - /// Subsequent calls are no-ops — this matters on iOS, where the SDK can be - /// re-initialised by tests and on Android where the JNI library may be loaded - /// more than once across the lifetime of a host process. - void install() noexcept; - - /// Swap the sink that receives forwarded callbacks. Pass `nullptr` to detach. - /// Thread-safe; uses an atomic shared-pointer swap. - void setSink(std::shared_ptr sink) noexcept; - - /// Read the currently installed sink, or `nullptr` if none. The returned - /// pointer is captured at the moment of call and remains valid for the - /// caller's reference count even if a concurrent `setSink` swaps the sink. - std::shared_ptr sink() const noexcept; - - /// Runtime enable / disable. Defaults to `false`. When `false`, the logger - /// fast-paths every callback to a single atomic load — no virtual dispatch, - /// no sink lookup. This is the gate the public `enableTurboModuleTracking` - /// JS option toggles. - void setEnabled(bool enabled) noexcept; - bool isEnabled() const noexcept; - - private: - SentryTurboModulePerfController() noexcept = default; - - std::atomic installed_{false}; - std::atomic enabled_{false}; - - // Sink storage. We use a raw mutex + shared_ptr rather than - // `std::atomic>` because the latter is C++20 and not - // available on the older toolchains some downstream RN setups still use. - mutable std::mutex sink_mutex_; - std::shared_ptr sink_; +public: + /// Returns the process-wide controller instance. The controller owns the + /// installed logger and the active sink. + static SentryTurboModulePerfController &instance() noexcept; + + /// Idempotent install. The first call constructs a `SentryTurboModulePerfLogger` + /// and hands it to RN via `facebook::react::TurboModulePerfLogger::enableLogging`. + /// Subsequent calls are no-ops — this matters on iOS, where the SDK can be + /// re-initialised by tests and on Android where the JNI library may be loaded + /// more than once across the lifetime of a host process. + /// + /// Note: `setEnabled(true)` calls this lazily, so most consumers do not need + /// to invoke `install()` directly. Calling it explicitly is only useful when + /// a host wants to claim the perf logger slot before any other component + /// (Metro, another SDK) gets a chance to install its own. + void install() noexcept; + + /// Swap the sink that receives forwarded callbacks. Pass `nullptr` to detach. + /// Thread-safe; uses an atomic shared-pointer swap. + void setSink(std::shared_ptr sink) noexcept; + + /// Read the currently installed sink, or `nullptr` if none. The returned + /// pointer is captured at the moment of call and remains valid for the + /// caller's reference count even if a concurrent `setSink` swaps the sink. + std::shared_ptr sink() const noexcept; + + /// Runtime enable / disable. Defaults to `false`. When `false`, the logger + /// fast-paths every callback to a single atomic load — no virtual dispatch, + /// no sink lookup. This is the gate the public `enableTurboModuleTracking` + /// JS option toggles. + void setEnabled(bool enabled) noexcept; + bool isEnabled() const noexcept; + +private: + SentryTurboModulePerfController() noexcept = default; + + std::atomic installed_ { false }; + std::atomic enabled_ { false }; + + // Sink storage. We use a raw mutex + shared_ptr rather than + // `std::atomic>` because the latter is C++20 and not + // available on the older toolchains some downstream RN setups still use. + mutable std::mutex sink_mutex_; + std::shared_ptr sink_; }; -} // namespace sentry::reactnative +} // namespace sentry::reactnative #ifdef __cplusplus extern "C" { #endif -/// One-call installer. Safe to call multiple times. -/// -/// - On iOS we call this from `RNSentry`'s init path so the logger is in place -/// before the bridge starts creating modules. -/// - On Android we call this from `JNI_OnLoad` inside `libsentry-tm-perf-logger.so`, -/// which is loaded by `RNSentryPackage`'s static initializer. +/// One-call installer. Safe to call multiple times. The default flow does not +/// invoke this directly — `Sentry_SetTurboModuleTrackingEnabled(1)` lazily +/// installs the logger on first enable. Provided for hosts that want to claim +/// the perf-logger slot eagerly before any other component does. void Sentry_InstallTurboModulePerfLogger(void); -/// Runtime flag toggled from JS via `RNSentry.enableTurboModuleTracking`. The -/// underlying logger is always installed (so we don't miss the early lifecycle -/// events); this gate just decides whether forwarded callbacks reach the sink. +/// Runtime flag toggled from JS via `RNSentry.enableTurboModuleTracking`. +/// On first transition to `enabled = 1` this also installs the underlying +/// `NativeModulePerfLogger` into React Native; before that point the perf-logger +/// slot is left untouched so we never evict another component's logger while +/// tracking is off. void Sentry_SetTurboModuleTrackingEnabled(int enabled); #ifdef __cplusplus -} // extern "C" +} // extern "C" #endif diff --git a/packages/core/cpp/SentryTurboModulePerfSink.h b/packages/core/cpp/SentryTurboModulePerfSink.h index 95c9078da6..117952a40f 100644 --- a/packages/core/cpp/SentryTurboModulePerfSink.h +++ b/packages/core/cpp/SentryTurboModulePerfSink.h @@ -36,63 +36,73 @@ namespace sentry::reactnative { /// Pointers passed in (`moduleName`, `methodName`) are owned by React Native; /// the sink may inspect them during the call but MUST NOT retain them past it. class ISentryTurboModulePerfSink { - public: - virtual ~ISentryTurboModulePerfSink() = default; +public: + virtual ~ISentryTurboModulePerfSink() = default; - // ---- Module data / create (iOS NativeModule two-phase, Android single phase) - virtual void moduleDataCreateStart(const char* moduleName, int32_t id) = 0; - virtual void moduleDataCreateEnd(const char* moduleName, int32_t id) = 0; - virtual void moduleCreateStart(const char* moduleName, int32_t id) = 0; - virtual void moduleCreateCacheHit(const char* moduleName, int32_t id) = 0; - virtual void moduleCreateConstructStart(const char* moduleName, int32_t id) = 0; - virtual void moduleCreateConstructEnd(const char* moduleName, int32_t id) = 0; - virtual void moduleCreateSetUpStart(const char* moduleName, int32_t id) = 0; - virtual void moduleCreateSetUpEnd(const char* moduleName, int32_t id) = 0; - virtual void moduleCreateEnd(const char* moduleName, int32_t id) = 0; - virtual void moduleCreateFail(const char* moduleName, int32_t id) = 0; + // ---- Module data / create (iOS NativeModule two-phase, Android single phase) + virtual void moduleDataCreateStart(const char *moduleName, int32_t id) = 0; + virtual void moduleDataCreateEnd(const char *moduleName, int32_t id) = 0; + virtual void moduleCreateStart(const char *moduleName, int32_t id) = 0; + virtual void moduleCreateCacheHit(const char *moduleName, int32_t id) = 0; + virtual void moduleCreateConstructStart(const char *moduleName, int32_t id) = 0; + virtual void moduleCreateConstructEnd(const char *moduleName, int32_t id) = 0; + virtual void moduleCreateSetUpStart(const char *moduleName, int32_t id) = 0; + virtual void moduleCreateSetUpEnd(const char *moduleName, int32_t id) = 0; + virtual void moduleCreateEnd(const char *moduleName, int32_t id) = 0; + virtual void moduleCreateFail(const char *moduleName, int32_t id) = 0; - // ---- JS require timings (separate from create — they bracket the `require()` call itself) - virtual void moduleJSRequireBeginningStart(const char* moduleName) = 0; - virtual void moduleJSRequireBeginningCacheHit(const char* moduleName) = 0; - virtual void moduleJSRequireBeginningEnd(const char* moduleName) = 0; - virtual void moduleJSRequireBeginningFail(const char* moduleName) = 0; - virtual void moduleJSRequireEndingStart(const char* moduleName) = 0; - virtual void moduleJSRequireEndingEnd(const char* moduleName) = 0; - virtual void moduleJSRequireEndingFail(const char* moduleName) = 0; + // ---- JS require timings (separate from create — they bracket the `require()` call itself) + virtual void moduleJSRequireBeginningStart(const char *moduleName) = 0; + virtual void moduleJSRequireBeginningCacheHit(const char *moduleName) = 0; + virtual void moduleJSRequireBeginningEnd(const char *moduleName) = 0; + virtual void moduleJSRequireBeginningFail(const char *moduleName) = 0; + virtual void moduleJSRequireEndingStart(const char *moduleName) = 0; + virtual void moduleJSRequireEndingEnd(const char *moduleName) = 0; + virtual void moduleJSRequireEndingFail(const char *moduleName) = 0; - // ---- Sync method calls (blocking from JS) - virtual void syncMethodCallStart(const char* moduleName, const char* methodName) = 0; - virtual void syncMethodCallArgConversionStart(const char* moduleName, const char* methodName) = 0; - virtual void syncMethodCallArgConversionEnd(const char* moduleName, const char* methodName) = 0; - virtual void syncMethodCallExecutionStart(const char* moduleName, const char* methodName) = 0; - virtual void syncMethodCallExecutionEnd(const char* moduleName, const char* methodName) = 0; - virtual void syncMethodCallReturnConversionStart(const char* moduleName, const char* methodName) = 0; - virtual void syncMethodCallReturnConversionEnd(const char* moduleName, const char* methodName) = 0; - virtual void syncMethodCallEnd(const char* moduleName, const char* methodName) = 0; - virtual void syncMethodCallFail(const char* moduleName, const char* methodName) = 0; + // ---- Sync method calls (blocking from JS) + virtual void syncMethodCallStart(const char *moduleName, const char *methodName) = 0; + virtual void syncMethodCallArgConversionStart(const char *moduleName, const char *methodName) + = 0; + virtual void syncMethodCallArgConversionEnd(const char *moduleName, const char *methodName) = 0; + virtual void syncMethodCallExecutionStart(const char *moduleName, const char *methodName) = 0; + virtual void syncMethodCallExecutionEnd(const char *moduleName, const char *methodName) = 0; + virtual void syncMethodCallReturnConversionStart(const char *moduleName, const char *methodName) + = 0; + virtual void syncMethodCallReturnConversionEnd(const char *moduleName, const char *methodName) + = 0; + virtual void syncMethodCallEnd(const char *moduleName, const char *methodName) = 0; + virtual void syncMethodCallFail(const char *moduleName, const char *methodName) = 0; - // ---- Async method calls (Promise-returning from JS) - // - // The async surface is split into two halves: - // - The "call" half fires on the JS thread (`asyncMethodCall{Start,Dispatch,End,Fail}`). - // - The "execution" half fires on the native module's executor when the - // queued call actually runs (`asyncMethodCallExecution{Start,End,Fail}`), - // carrying an `id` to correlate the two halves. - virtual void asyncMethodCallStart(const char* moduleName, const char* methodName) = 0; - virtual void asyncMethodCallArgConversionStart(const char* moduleName, const char* methodName) = 0; - virtual void asyncMethodCallArgConversionEnd(const char* moduleName, const char* methodName) = 0; - virtual void asyncMethodCallDispatch(const char* moduleName, const char* methodName) = 0; - virtual void asyncMethodCallEnd(const char* moduleName, const char* methodName) = 0; - virtual void asyncMethodCallFail(const char* moduleName, const char* methodName) = 0; + // ---- Async method calls (Promise-returning from JS) + // + // The async surface is split into two halves: + // - The "call" half fires on the JS thread (`asyncMethodCall{Start,Dispatch,End,Fail}`). + // - The "execution" half fires on the native module's executor when the + // queued call actually runs (`asyncMethodCallExecution{Start,End,Fail}`), + // carrying an `id` to correlate the two halves. + virtual void asyncMethodCallStart(const char *moduleName, const char *methodName) = 0; + virtual void asyncMethodCallArgConversionStart(const char *moduleName, const char *methodName) + = 0; + virtual void asyncMethodCallArgConversionEnd(const char *moduleName, const char *methodName) + = 0; + virtual void asyncMethodCallDispatch(const char *moduleName, const char *methodName) = 0; + virtual void asyncMethodCallEnd(const char *moduleName, const char *methodName) = 0; + virtual void asyncMethodCallFail(const char *moduleName, const char *methodName) = 0; - virtual void asyncMethodCallBatchPreprocessStart() = 0; - virtual void asyncMethodCallBatchPreprocessEnd(int batchSize) = 0; + virtual void asyncMethodCallBatchPreprocessStart() = 0; + virtual void asyncMethodCallBatchPreprocessEnd(int batchSize) = 0; - virtual void asyncMethodCallExecutionStart(const char* moduleName, const char* methodName, int32_t id) = 0; - virtual void asyncMethodCallExecutionArgConversionStart(const char* moduleName, const char* methodName, int32_t id) = 0; - virtual void asyncMethodCallExecutionArgConversionEnd(const char* moduleName, const char* methodName, int32_t id) = 0; - virtual void asyncMethodCallExecutionEnd(const char* moduleName, const char* methodName, int32_t id) = 0; - virtual void asyncMethodCallExecutionFail(const char* moduleName, const char* methodName, int32_t id) = 0; + virtual void asyncMethodCallExecutionStart( + const char *moduleName, const char *methodName, int32_t id) = 0; + virtual void asyncMethodCallExecutionArgConversionStart( + const char *moduleName, const char *methodName, int32_t id) = 0; + virtual void asyncMethodCallExecutionArgConversionEnd( + const char *moduleName, const char *methodName, int32_t id) = 0; + virtual void asyncMethodCallExecutionEnd( + const char *moduleName, const char *methodName, int32_t id) = 0; + virtual void asyncMethodCallExecutionFail( + const char *moduleName, const char *methodName, int32_t id) = 0; }; -} // namespace sentry::reactnative +} // namespace sentry::reactnative diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 2437e61a63..dcf8d5a402 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -67,27 +67,6 @@ - (instancetype)initWithDictionary:(NSDictionary *)dictionary; static bool hasFetchedAppStart; -// Install the TurboModule perf logger as early as possible. The `+load` method -// on `RNSentry` itself is reserved by `RCT_EXPORT_MODULE()` (which generates -// its own `+load` to register the module with React Native), so we host the -// install hook on a separate dummy class. Both `+load`s run before any module -// instantiation, so the order between them does not matter — we just need -// ours to fire before `RCTBridge` / `RCTHost` create their first TurboModule. -// -// The install is idempotent (the controller short-circuits on subsequent -// calls) and free when the `enableTurboModuleTracking` runtime flag is off, -// which is the default. On Old Architecture this compiles to a no-op -// installer. -@interface RNSentryTurboModulePerfLoggerInstaller : NSObject -@end - -@implementation RNSentryTurboModulePerfLoggerInstaller -+ (void)load -{ - Sentry_InstallTurboModulePerfLogger(); -} -@end - @implementation RNSentry { bool hasListeners; bool _shakeDetectionEnabled; @@ -185,7 +164,8 @@ - (NSMutableDictionary *)prepareOptions:(NSDictionary *)options // whether forwarded callbacks reach the Sentry sink. id enableTurboModuleTracking = [options objectForKey:@"enableTurboModuleTracking"]; if ([enableTurboModuleTracking isKindOfClass:[NSNumber class]]) { - Sentry_SetTurboModuleTrackingEnabled([(NSNumber *)enableTurboModuleTracking boolValue] ? 1 : 0); + Sentry_SetTurboModuleTrackingEnabled( + [(NSNumber *)enableTurboModuleTracking boolValue] ? 1 : 0); } NSError *error = nil; From 8b7286b935b074d4533fcf63fc83e5f6db82f08b Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 11:06:05 +0200 Subject: [PATCH 04/22] fix(turbomodule): Publish enabled flag before lazy-installing the logger Address Cursor's low-severity finding on #6307: `setEnabled(true)` was storing `enabled_` *after* calling `install()`, so any callback React Native fired synchronously from inside `enableLogging()` would hit the `isEnabled() == false` fast-path and be dropped \u2014 a tiny window of lost events for the very first opted-in invocation. Swap the order: publish `enabled_ = true` (release ordering) before the install, so by the time `enableLogging()` could re-enter us via a synchronous callback, the flag is already visible to other threads. On disable the order does not matter since we never uninstall. --- packages/core/cpp/SentryTurboModulePerfLogger.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.cpp b/packages/core/cpp/SentryTurboModulePerfLogger.cpp index c22ce351ec..8776d7fa84 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.cpp +++ b/packages/core/cpp/SentryTurboModulePerfLogger.cpp @@ -189,6 +189,12 @@ SentryTurboModulePerfController::install() noexcept void SentryTurboModulePerfController::setEnabled(bool enabled) noexcept { + // Publish the new flag *before* installing the logger so any callback RN + // fires synchronously from inside `enableLogging()` already sees + // `isEnabled() == true` and reaches the sink instead of being dropped by + // the fast-path. On disable, order does not matter — we never uninstall. + enabled_.store(enabled, std::memory_order_release); + // Enabling tracking lazily installs the logger. This avoids evicting any // pre-existing `NativeModulePerfLogger` (Metro, other SDKs, host-app // instrumentation) when the user has not opted in to TurboModule tracking, @@ -196,7 +202,6 @@ SentryTurboModulePerfController::setEnabled(bool enabled) noexcept if (enabled) { install(); } - enabled_.store(enabled, std::memory_order_release); } void From e3dd8b14043ed3df72f0e356c10079e200ca2d4d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 11:58:13 +0200 Subject: [PATCH 05/22] fix(turbomodule,android): Drop link-time --strip-all so AGP can ship debug info Address Warden's medium-severity finding on #6307: passing `-Wl,--strip-all` at CMake link time strips DWARF (and `.symtab`) from `libsentry-tm-perf-logger.so` *before* AGP's `StripDebugSymbolsTask` gets a chance to copy the unstripped artefact for symbolication upload. Any crash inside the library in production would be unsymbolicated even with the Sentry Gradle plugin installed. Drop the manual link option entirely. AGP already strips the .so for the packaged APK while preserving the unstripped copy under `intermediates/merged_native_libs/.../obj`, which is the one Sentry Gradle plugin uploads. Verified locally with `llvm-readelf -S` on the release intermediate: `.debug_*` and `.symtab` sections are now present. --- packages/core/android/CMakeLists.txt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/core/android/CMakeLists.txt b/packages/core/android/CMakeLists.txt index f0abd0128c..09bcaf53ed 100644 --- a/packages/core/android/CMakeLists.txt +++ b/packages/core/android/CMakeLists.txt @@ -56,7 +56,10 @@ target_link_libraries( ReactAndroid::reactnative ) -# Strip symbols in release builds to keep the AAR small. -target_link_options(sentry-tm-perf-logger PRIVATE - "$<$:-Wl,--strip-all>" -) +# Note: we deliberately do NOT pass `-Wl,--strip-all` (or similar) here. +# Android Gradle Plugin's `StripDebugSymbolsTask` already strips the .so for +# the packaged APK while preserving the unstripped artefact under +# `intermediates/merged_native_libs/.../obj`, which the Sentry Gradle plugin +# uploads for crash symbolication. Stripping at link time would erase DWARF +# before AGP can copy it, leaving any crash inside this library +# unsymbolicated in production. From 313197262d6d8a69f87418f2108d0ac9f658ba73 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 13:15:14 +0200 Subject: [PATCH 06/22] fix(turbomodule): Toggle tracking only after native SDK starts, guard type Two related findings on #6307: - Cursor MEDIUM (iOS): `Sentry_SetTurboModuleTrackingEnabled` ran *before* `RNSentryStart.startWithOptions`. If init failed and the promise rejected, tracking was already on (and would lazy-install Sentry's perf logger into React Native) while no native SDK was around to consume the data. - Sentry bot LOW (Android): `rnOptions.getBoolean("enableTurboModuleTracking")` was guarded only by `hasKey`. A non-boolean value from JS (number, string, null) would crash with `UnexpectedNativeTypeException`. Move the toggle on both platforms to AFTER `startWithOptions` so nothing is enabled unless the native SDK actually started, and add an explicit `ReadableType.Boolean` check on Android to match the iOS\n`isKindOfClass:[NSNumber class]` guard. --- .../io/sentry/react/RNSentryModuleImpl.java | 23 ++++++++++++------- packages/core/ios/RNSentry.mm | 20 ++++++++-------- .../project.pbxproj | 2 ++ 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index fd2336bcf9..21bcdc0d5e 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -23,6 +23,7 @@ import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.ReadableType; import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; @@ -192,14 +193,6 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { // Set the React context for the logger so it can forward logs to JS rnLogger.setReactContext(this.reactApplicationContext); - // Toggle the TurboModule perf-logger sink based on the JS option. The - // logger itself is already installed (see `RNSentryPackage`'s static - // initializer + `libsentry-tm-perf-logger.so` JNI hook); this just gates - // whether forwarded callbacks reach the Sentry sink. No-op on Old Arch. - if (rnOptions.hasKey("enableTurboModuleTracking")) { - RNSentryTurboModulePerfTracker.setEnabled(rnOptions.getBoolean("enableTurboModuleTracking")); - } - RNSentryStart.startWithOptions( getApplicationContext(), rnOptions, @@ -210,6 +203,20 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { }, logger); + // Toggle the TurboModule perf-logger sink based on the JS option. The + // sink lazy-installs the native `NativeModulePerfLogger` on first enable; + // we therefore want this to run only after the native SDK has started + // successfully — otherwise we'd claim React Native's perf-logger slot + // while no Sentry SDK is around to consume the data. + // + // The explicit `ReadableType.Boolean` check guards against JS passing a + // non-boolean (number, string, null) for the option, which would crash + // `getBoolean` with `UnexpectedNativeTypeException`. + if (rnOptions.hasKey("enableTurboModuleTracking") + && rnOptions.getType("enableTurboModuleTracking") == ReadableType.Boolean) { + RNSentryTurboModulePerfTracker.setEnabled(rnOptions.getBoolean("enableTurboModuleTracking")); + } + promise.resolve(true); } diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index dcf8d5a402..291fe55069 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -159,15 +159,6 @@ - (NSMutableDictionary *)prepareOptions:(NSDictionary *)options { NSMutableDictionary *mutableOptions = [self prepareOptions:options]; - // Toggle the TurboModule perf-logger sink based on the JS option. The - // logger itself is already installed (see +load); this just decides - // whether forwarded callbacks reach the Sentry sink. - id enableTurboModuleTracking = [options objectForKey:@"enableTurboModuleTracking"]; - if ([enableTurboModuleTracking isKindOfClass:[NSNumber class]]) { - Sentry_SetTurboModuleTrackingEnabled( - [(NSNumber *)enableTurboModuleTracking boolValue] ? 1 : 0); - } - NSError *error = nil; [RNSentryStart startWithOptions:mutableOptions error:&error]; if (error != nil) { @@ -175,6 +166,17 @@ - (NSMutableDictionary *)prepareOptions:(NSDictionary *)options return; } + // Toggle the TurboModule perf-logger sink based on the JS option. Only + // do this after the native SDK has started successfully — otherwise a + // rejected `initNativeSdk` would still leave tracking on (and would + // claim the perf-logger slot via lazy install) while no SDK is around to + // receive the data. + id enableTurboModuleTracking = [options objectForKey:@"enableTurboModuleTracking"]; + if ([enableTurboModuleTracking isKindOfClass:[NSNumber class]]) { + Sentry_SetTurboModuleTrackingEnabled( + [(NSNumber *)enableTurboModuleTracking boolValue] ? 1 : 0); + } + // RNSentryStart.startWithOptions already handles: // - Session tracking notification (SentryHybridSdkDidBecomeActive) // - Replay postInit diff --git a/samples/react-native/ios/sentryreactnativesample.xcodeproj/project.pbxproj b/samples/react-native/ios/sentryreactnativesample.xcodeproj/project.pbxproj index 25dae5e25a..16940e36c3 100644 --- a/samples/react-native/ios/sentryreactnativesample.xcodeproj/project.pbxproj +++ b/samples/react-native/ios/sentryreactnativesample.xcodeproj/project.pbxproj @@ -630,6 +630,7 @@ "$(inherited)", " ", ); + PODFILE_DIR = "$(SRCROOT)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -709,6 +710,7 @@ "$(inherited)", " ", ); + PODFILE_DIR = "$(SRCROOT)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ENABLE_EXPLICIT_MODULES = NO; From 542444f22d6b5321dae3cc1d6b5d0af2b14b96e5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 14:01:34 +0200 Subject: [PATCH 07/22] fix(turbomodule,android): Lazy library load, package cpp/, shorter changelog Round of fixes from PR #6307 review: - antonis: `System.loadLibrary("sentry-tm-perf-logger")` is now lazy inside `RNSentryTurboModulePerfTracker` instead of running from `RNSentryPackage`'s static initializer. Hosts that never opt in to `enableTurboModuleTracking` no longer pay the (small but non-zero) cost of mapping a shared library they will never call into. Failed loads still latch `nativeUnavailable` permanently, so we never retry. Updated `@TestOnly` reset to also clear `libraryLoadAttempted`. - antonis: comment in `RNSentryPackage.java` referenced a JNI_OnLoad install path that no longer exists (we made the install lazy in an earlier commit). Replaced with a short note pointing at the tracker. - antonis: add `!/cpp/**/*` to `.npmignore` so the shared C++ sources\n are actually published to npm \u2014 the iOS podspec needs to compile\n them and the previous ignore list shipped an empty `cpp/` directory. - antonis: collapsed the changelog entry to the requested short form\n ("Add `enableTurboModuleTracking` opt-in experimental option to\n enable Turbo Module performance tracking in the New Architecture"). The Sentry bot MEDIUM about Android tracking-on-init-fail was already\nresolved by the previous ordering change \u2014 `startWithOptions` throws on\nfailure and the call to `setEnabled` is now after that point, so a\nfailed init never reaches the toggle. --- CHANGELOG.md | 2 +- packages/core/.npmignore | 1 + .../java/io/sentry/react/RNSentryPackage.java | 26 ++------ .../react/RNSentryTurboModulePerfTracker.java | 64 ++++++++++++++++--- 4 files changed, 61 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c670a21b0..b2168b8b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ ### Features -- Wire Sentry's `facebook::react::NativeModulePerfLogger` on both platforms so the SDK observes every TurboModule lifecycle event (`moduleCreate*`, sync/async method call start/end/fail, execution start/end/fail) for crash attribution, per-module spans and aggregated stats in follow-up releases. Install is automatic — no `OnLoad.cpp` changes on Android. Gated by the new `enableTurboModuleTracking` option on `Sentry.init`, default `false` for this first release. New Architecture only ([#6307](https://github.com/getsentry/sentry-react-native/pull/6307)) +- Add `enableTurboModuleTracking` opt-in experimental option to enable Turbo Module performance tracking in the New Architecture ([#6307](https://github.com/getsentry/sentry-react-native/pull/6307)) - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) - Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) diff --git a/packages/core/.npmignore b/packages/core/.npmignore index bbde34660c..871ec44541 100644 --- a/packages/core/.npmignore +++ b/packages/core/.npmignore @@ -14,6 +14,7 @@ !react-native.config.js !/ios/**/* !/android/**/* +!/cpp/**/* # New Architecture Codegen !src/js/NativeRNSentry.ts diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryPackage.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryPackage.java index 97b2036858..40941df3c8 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryPackage.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryPackage.java @@ -1,6 +1,5 @@ package io.sentry.react; -import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.facebook.react.TurboReactPackage; @@ -21,26 +20,11 @@ public class RNSentryPackage extends TurboReactPackage { private static final boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; - static { - // Load `libsentry-tm-perf-logger.so` as early as possible — its - // `JNI_OnLoad` installs Sentry's `facebook::react::NativeModulePerfLogger` - // into React Native so the SDK observes every TurboModule lifecycle event. - // - // The library is only built under New Architecture (see `build.gradle` and - // `CMakeLists.txt`). On Old Architecture there is no TurboModule perf - // logger to install, so a missing `.so` is expected and we swallow the - // `UnsatisfiedLinkError` instead of crashing the host. - try { - System.loadLibrary("sentry-tm-perf-logger"); - } catch (UnsatisfiedLinkError e) { - // Expected on Old Arch and on hosts that strip Sentry's native - // libraries; the SDK keeps working with only Java-side instrumentation. - Log.i( - "RNSentry", - "libsentry-tm-perf-logger.so not loaded; TurboModule perf tracking unavailable: " - + e.getMessage()); - } - } + // `libsentry-tm-perf-logger.so` is loaded lazily inside + // `RNSentryTurboModulePerfTracker.setEnabled(true)`, not from this class's + // static initializer. That way hosts that do not opt in to + // `enableTurboModuleTracking` never pay the (small but non-zero) cost of + // mapping a shared library they will never call into. @Nullable @Override diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java index 9b2fbdf480..995b2ec970 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java @@ -5,40 +5,57 @@ import org.jetbrains.annotations.TestOnly; /** - * Thin Java façade over the native runtime flag installed by {@code libsentry-tm-perf-logger.so}. + * Thin Java façade over the native runtime flag exposed by {@code libsentry-tm-perf-logger.so}. + * + *

The native library is loaded lazily on the first call to {@link #setEnabled(boolean)}, not + * from a static initializer. Hosts that never opt in to {@code enableTurboModuleTracking} pay no + * shared-library mapping cost; the {@code .so} is only resolved when tracking is actually toggled + * on. We never call {@code System.loadLibrary} again once it has failed once. * *

The native library is only built when the consuming app is using React Native's New * Architecture (see {@code CMakeLists.txt} and {@code build.gradle}). On Old Architecture the - * underlying {@code .so} is not packaged, so the first call to {@link #setEnabled(boolean)} hits an - * {@link UnsatisfiedLinkError} which we swallow — TurboModule perf tracking is a no-op there. - * - *

We deliberately keep the linkage check lazy (try-catch on first invocation) instead of probing - * at class load time so that the SDK's {@code initNativeSdk} call path stays the single source of - * truth for whether tracking is on. + * underlying {@code .so} is not packaged, so {@link #setEnabled(boolean)} hits an {@link + * UnsatisfiedLinkError} which we swallow — TurboModule perf tracking is a no-op there. */ public final class RNSentryTurboModulePerfTracker { private static final String TAG = "RNSentry"; + private static final String LIB_NAME = "sentry-tm-perf-logger"; /** * Remembers whether we have already discovered the native symbol to be missing. After the first - * UnsatisfiedLinkError we stop trying — there is no scenario where the link suddenly succeeds - * within the same process lifetime. Using `AtomicBoolean` instead of `volatile` to satisfy the - * project-wide PMD rule (`AvoidUsingVolatile`). + * {@code UnsatisfiedLinkError} we stop trying — there is no scenario where the link suddenly + * succeeds within the same process lifetime. Using {@code AtomicBoolean} instead of {@code + * volatile} to satisfy the project-wide PMD rule ({@code AvoidUsingVolatile}). */ private static final AtomicBoolean nativeUnavailable = new AtomicBoolean(false); + /** + * Tracks whether {@link System#loadLibrary(String)} has already been attempted (regardless of + * outcome) so the second and later {@link #setEnabled(boolean)} calls do not re-run the load. + * Combined with {@link #nativeUnavailable} this gives us a one-way state machine: not + * loadedloaded or permanently unavailable. + */ + private static final AtomicBoolean libraryLoadAttempted = new AtomicBoolean(false); + private RNSentryTurboModulePerfTracker() {} /** * Toggle the perf-logger sink. When {@code false} (the default) every TurboModule callback the * logger receives is dropped after one atomic check — there is effectively no overhead. When * {@code true} the callback is forwarded to whichever sink is currently installed in C++. + * + *

The first invocation lazily loads {@code libsentry-tm-perf-logger.so}; subsequent calls + * reuse the already-loaded library. A missing {@code .so} (Old Architecture, stripped binary) + * permanently latches the tracker into a no-op state. */ public static void setEnabled(boolean enabled) { if (nativeUnavailable.get()) { return; } + if (!ensureNativeLibraryLoaded()) { + return; + } try { nativeSetEnabled(enabled); } catch (UnsatisfiedLinkError e) { @@ -49,6 +66,32 @@ public static void setEnabled(boolean enabled) { } } + /** + * Attempts {@code System.loadLibrary} once and remembers the outcome. Returns {@code true} when + * the library is (or just became) available, {@code false} when it could not be loaded. + */ + private static boolean ensureNativeLibraryLoaded() { + if (!libraryLoadAttempted.compareAndSet(false, true)) { + // Another caller already tried. The outcome is encoded in `nativeUnavailable`. + return !nativeUnavailable.get(); + } + try { + System.loadLibrary(LIB_NAME); + return true; + } catch (UnsatisfiedLinkError e) { + // Expected on Old Arch and on hosts that strip Sentry's native libraries; the SDK keeps + // working with only Java-side instrumentation. + nativeUnavailable.set(true); + Log.i( + TAG, + "lib" + + LIB_NAME + + ".so not loaded; TurboModule perf tracking unavailable: " + + e.getMessage()); + return false; + } + } + private static native void nativeSetEnabled(boolean enabled); @TestOnly @@ -59,5 +102,6 @@ public static boolean isNativeUnavailableForTests() { @TestOnly public static void resetNativeUnavailableForTests() { nativeUnavailable.set(false); + libraryLoadAttempted.set(false); } } From 3d0c73bc576e02a05832d6a5e7a8d084779fd385 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 14:42:53 +0200 Subject: [PATCH 08/22] fix(turbomodule): Synchronize lazy lib load, lock-free hot-path sink read Two findings from the latest PR #6307 review pass: - **Cursor MEDIUM / Sentry bot LOW** (Android): the compareAndSet-based\n lazy `System.loadLibrary` had a race where a second thread could\n return success from `ensureNativeLibraryLoaded` while the first\n thread was still loading the library, then call `nativeSetEnabled`,\n hit `UnsatisfiedLinkError`, and latch `nativeUnavailable` for the\n lifetime of the process. Replace with a synchronized block that\n blocks concurrent first callers on the in-progress load; the\n monitor is bypassed on the fast path once the load completes (the\n early-return on the `AtomicBoolean` flag is preserved). The flags\n are now flipped *after* the load result is established so any reader\n observing `libraryLoadAttempted == true` sees a matching\n `nativeUnavailable` value.\n\n- **Warden MEDIUM** (C++): every forwarded TurboModule callback was\n acquiring `sink_mutex_` to read the sink \u2014 including sync method\n calls on the JS thread, which the sink interface explicitly\n documents must never block. Introduce a two-level storage: keep the\n owning `shared_ptr` (`sink_owner_`) under the mutex for lifetime\n management and for the strong-ownership `sink()` accessor used by\n tests; mirror it to a lock-free `std::atomic` (`sink_cache_`)\n read by a new `sinkRaw()` accessor on the hot path. The forwarder\n macros now read via `sinkRaw()` so every TurboModule callback hits\n the sink with a single acquire-load and no mutex.\n\n The trade-off is a lifetime contract documented on `setSink`: a sink\n installed via `setSink(s)` must remain valid until either the\n controller is destroyed or a subsequent `setSink` call has completed.\n This matches our actual usage \u2014 the SDK installs the sink once at\n init and keeps it alive for the process lifetime \u2014 and avoids\n requiring C++20 atomic-shared_ptr support across every supported RN\n toolchain. --- .../react/RNSentryTurboModulePerfTracker.java | 49 +++++++++++++------ .../core/cpp/SentryTurboModulePerfLogger.cpp | 39 +++++++++++---- .../core/cpp/SentryTurboModulePerfLogger.h | 45 ++++++++++++++--- 3 files changed, 100 insertions(+), 33 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java index 995b2ec970..75219ec14a 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java @@ -69,26 +69,43 @@ public static void setEnabled(boolean enabled) { /** * Attempts {@code System.loadLibrary} once and remembers the outcome. Returns {@code true} when * the library is (or just became) available, {@code false} when it could not be loaded. + * + *

Synchronized so concurrent first callers block on the in-progress load instead of racing + * past it and hitting a phantom {@code UnsatisfiedLinkError} on {@code nativeSetEnabled} — which + * would then latch the tracker into a permanent no-op state for the lifetime of the process. The + * synchronization cost is paid at most a few times per process: once the load completes, every + * subsequent caller short-circuits on the early {@code libraryLoadAttempted} check before + * entering the monitor. */ private static boolean ensureNativeLibraryLoaded() { - if (!libraryLoadAttempted.compareAndSet(false, true)) { - // Another caller already tried. The outcome is encoded in `nativeUnavailable`. + if (libraryLoadAttempted.get()) { return !nativeUnavailable.get(); } - try { - System.loadLibrary(LIB_NAME); - return true; - } catch (UnsatisfiedLinkError e) { - // Expected on Old Arch and on hosts that strip Sentry's native libraries; the SDK keeps - // working with only Java-side instrumentation. - nativeUnavailable.set(true); - Log.i( - TAG, - "lib" - + LIB_NAME - + ".so not loaded; TurboModule perf tracking unavailable: " - + e.getMessage()); - return false; + synchronized (RNSentryTurboModulePerfTracker.class) { + // Re-check under the monitor in case another thread completed the load while we were + // queued for the lock. + if (libraryLoadAttempted.get()) { + return !nativeUnavailable.get(); + } + try { + System.loadLibrary(LIB_NAME); + // Set the attempted flag last so any reader that observes it also sees the matching + // `nativeUnavailable` state established above. + libraryLoadAttempted.set(true); + return true; + } catch (UnsatisfiedLinkError e) { + // Expected on Old Arch and on hosts that strip Sentry's native libraries; the SDK keeps + // working with only Java-side instrumentation. + nativeUnavailable.set(true); + libraryLoadAttempted.set(true); + Log.i( + TAG, + "lib" + + LIB_NAME + + ".so not loaded; TurboModule perf tracking unavailable: " + + e.getMessage()); + return false; + } } } diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.cpp b/packages/core/cpp/SentryTurboModulePerfLogger.cpp index 8776d7fa84..97af60e6fb 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.cpp +++ b/packages/core/cpp/SentryTurboModulePerfLogger.cpp @@ -35,10 +35,16 @@ namespace { /// swapped without re-installing the logger. class ForwardingLogger final : public facebook::react::NativeModulePerfLogger { public: - // The macro below lets us keep this file readable. Without it we'd have - // ~30 near-identical 5-line method bodies; with it the surface fits on one - // screen and any divergence between RN's API and ours surfaces as a compile - // error rather than a silent drop. + // The macros below let us keep this file readable. Without them we'd + // have ~30 near-identical method bodies; with them the surface fits on + // one screen and any divergence between RN's API and ours surfaces as + // a compile error rather than a silent drop. + // + // Each forwarder uses the lock-free `sinkRaw()` accessor instead of + // the owning `sink()` shared_ptr getter so a TurboModule sync method + // call — which fires on the JS thread — does not acquire a mutex on + // its critical path. The sink lifetime contract documented on + // `SentryTurboModulePerfController::setSink` keeps this safe. # define SENTRY_FORWARD0(name) \ void name() override \ { \ @@ -46,7 +52,7 @@ namespace { if (!c.isEnabled()) { \ return; \ } \ - if (auto sink = c.sink()) { \ + if (auto *sink = c.sinkRaw()) { \ sink->name(); \ } \ } @@ -58,7 +64,7 @@ namespace { if (!c.isEnabled()) { \ return; \ } \ - if (auto sink = c.sink()) { \ + if (auto *sink = c.sinkRaw()) { \ sink->name(arg1Name); \ } \ } @@ -70,7 +76,7 @@ namespace { if (!c.isEnabled()) { \ return; \ } \ - if (auto sink = c.sink()) { \ + if (auto *sink = c.sinkRaw()) { \ sink->name(n1, n2); \ } \ } @@ -82,7 +88,7 @@ namespace { if (!c.isEnabled()) { \ return; \ } \ - if (auto sink = c.sink()) { \ + if (auto *sink = c.sinkRaw()) { \ sink->name(n1, n2, n3); \ } \ } @@ -207,15 +213,28 @@ SentryTurboModulePerfController::setEnabled(bool enabled) noexcept void SentryTurboModulePerfController::setSink(std::shared_ptr sink) noexcept { + // Hold the mutex while we mutate both the owning `shared_ptr` and the + // hot-path raw pointer so any concurrent `sink()` reader observes a + // consistent pair. The raw pointer is published *after* the owning + // reference under release ordering so a reader that observes the new + // pointer also observes the new owning ref it points into. std::lock_guard lock(sink_mutex_); - sink_ = std::move(sink); + ISentryTurboModulePerfSink *raw = sink.get(); + sink_owner_ = std::move(sink); + sink_cache_.store(raw, std::memory_order_release); } std::shared_ptr SentryTurboModulePerfController::sink() const noexcept { std::lock_guard lock(sink_mutex_); - return sink_; + return sink_owner_; +} + +ISentryTurboModulePerfSink * +SentryTurboModulePerfController::sinkRaw() const noexcept +{ + return sink_cache_.load(std::memory_order_acquire); } bool diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.h b/packages/core/cpp/SentryTurboModulePerfLogger.h index 4bca219e00..467efecc7a 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.h +++ b/packages/core/cpp/SentryTurboModulePerfLogger.h @@ -61,14 +61,34 @@ class SentryTurboModulePerfController { void install() noexcept; /// Swap the sink that receives forwarded callbacks. Pass `nullptr` to detach. - /// Thread-safe; uses an atomic shared-pointer swap. + /// Thread-safe — takes the sink mutex to mirror the owning `shared_ptr` and + /// the hot-path raw pointer atomically. + /// + /// Lifetime contract: a sink installed via `setSink(s)` MUST remain valid + /// until either the controller is destroyed or a subsequent `setSink` call + /// has completed. Concretely — once the controller is enabled and a sink + /// is installed, callers must not destroy the sink (i.e. drop the last + /// `shared_ptr` reference to it elsewhere) while the controller may still + /// dispatch into it. In practice the SDK installs the sink once at init + /// and keeps it alive for the lifetime of the process, which is the case + /// this contract is tuned for; the hot path therefore reads the sink via + /// a lock-free atomic raw pointer for zero overhead per TurboModule call. void setSink(std::shared_ptr sink) noexcept; /// Read the currently installed sink, or `nullptr` if none. The returned - /// pointer is captured at the moment of call and remains valid for the - /// caller's reference count even if a concurrent `setSink` swaps the sink. + /// `shared_ptr` is captured atomically (under the sink mutex) so the caller + /// holds an owning reference even if a concurrent `setSink` swaps the sink. + /// Used by tests and by external code that needs strong-ownership semantics; + /// the perf-logger forwarder uses the lock-free `sinkRaw()` path instead. std::shared_ptr sink() const noexcept; + /// Hot-path read: returns the currently installed sink as a raw pointer + /// without acquiring any lock. The pointer is only valid for the duration + /// of a single TurboModule callback, and only safe under the lifetime + /// contract documented on `setSink`. Returns `nullptr` when no sink is + /// installed. + ISentryTurboModulePerfSink *sinkRaw() const noexcept; + /// Runtime enable / disable. Defaults to `false`. When `false`, the logger /// fast-paths every callback to a single atomic load — no virtual dispatch, /// no sink lookup. This is the gate the public `enableTurboModuleTracking` @@ -82,11 +102,22 @@ class SentryTurboModulePerfController { std::atomic installed_ { false }; std::atomic enabled_ { false }; - // Sink storage. We use a raw mutex + shared_ptr rather than - // `std::atomic>` because the latter is C++20 and not - // available on the older toolchains some downstream RN setups still use. + // Sink storage uses a two-level design: + // - `sink_owner_` holds the owning `shared_ptr` and is protected by + // `sink_mutex_`. Mutated by `setSink`; read by `sink()` for callers + // that need strong-ownership semantics (tests, introspection). + // - `sink_cache_` is a lock-free atomic raw pointer mirror used by the + // hot path (`sinkRaw()`). It lets every forwarded TurboModule callback + // read the sink without acquiring a mutex, which the sink interface + // explicitly requires of the forwarder. + // + // The atomic-shared_ptr variants are either C++20 (`std::atomic>`) + // or deprecated free functions (`std::atomic_load(std::shared_ptr*)`); the + // mutex+atomic-raw-pointer combo gives us lock-free reads on the hot path + // without requiring C++20 across every supported RN toolchain. mutable std::mutex sink_mutex_; - std::shared_ptr sink_; + std::shared_ptr sink_owner_; + std::atomic sink_cache_ { nullptr }; }; } // namespace sentry::reactnative From 5d6f39f09619dc9bff0cfa4d5cf683687b680457 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 14:59:06 +0200 Subject: [PATCH 09/22] fix(turbomodule): Revert hot-path raw pointer to avoid sink-swap UAF Cursor MEDIUM on #6307: the lock-free `sinkRaw()` pattern introduced to address Warden's earlier hot-path mutex concern is unsafe under a concurrent `setSink` \u2014 a forwarder callback holding the raw pointer can outlive the previous owning `shared_ptr` reference dropped by\nthe swap, causing a use-after-free. The two MEDIUM findings are in tension: Warden wanted no mutex on the hot path, Cursor wants no UAF. The honest cost analysis decides it: the mutex variant pays ~50\u201380 ns per forwarded callback (atomic load + mutex lock/unlock + `shared_ptr` copy) and only when tracking is opted in. At 10K TurboModule calls/sec that is ~0.08% CPU \u2014 the optimization was not worth a real UAF hazard. The default-off path stays at a single atomic load. Revert to mutex-on-hot-path: drop `sinkRaw()` and the `sink_cache_` atomic mirror, restore `sink_` as the single mutex- guarded owning reference, and have the forwarder macros invoke `sink()` again. The header now explains the trade-off explicitly so the next reviewer does not re-litigate it. --- .../core/cpp/SentryTurboModulePerfLogger.cpp | 36 ++++-------- .../core/cpp/SentryTurboModulePerfLogger.h | 55 ++++++------------- 2 files changed, 29 insertions(+), 62 deletions(-) diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.cpp b/packages/core/cpp/SentryTurboModulePerfLogger.cpp index 97af60e6fb..fbffaecdef 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.cpp +++ b/packages/core/cpp/SentryTurboModulePerfLogger.cpp @@ -40,11 +40,12 @@ namespace { // one screen and any divergence between RN's API and ours surfaces as // a compile error rather than a silent drop. // - // Each forwarder uses the lock-free `sinkRaw()` accessor instead of - // the owning `sink()` shared_ptr getter so a TurboModule sync method - // call — which fires on the JS thread — does not acquire a mutex on - // its critical path. The sink lifetime contract documented on - // `SentryTurboModulePerfController::setSink` keeps this safe. + // Each forwarder uses the owning `sink()` accessor: it acquires the + // sink mutex, copies the `shared_ptr`, and releases the lock before + // invoking the sink. That keeps the sink alive for the duration of + // the call regardless of a concurrent `setSink`. The mutex cost is + // only paid when `isEnabled()` returns true — when tracking is off + // (the default), the early return runs after a single atomic load. # define SENTRY_FORWARD0(name) \ void name() override \ { \ @@ -52,7 +53,7 @@ namespace { if (!c.isEnabled()) { \ return; \ } \ - if (auto *sink = c.sinkRaw()) { \ + if (auto sink = c.sink()) { \ sink->name(); \ } \ } @@ -64,7 +65,7 @@ namespace { if (!c.isEnabled()) { \ return; \ } \ - if (auto *sink = c.sinkRaw()) { \ + if (auto sink = c.sink()) { \ sink->name(arg1Name); \ } \ } @@ -76,7 +77,7 @@ namespace { if (!c.isEnabled()) { \ return; \ } \ - if (auto *sink = c.sinkRaw()) { \ + if (auto sink = c.sink()) { \ sink->name(n1, n2); \ } \ } @@ -88,7 +89,7 @@ namespace { if (!c.isEnabled()) { \ return; \ } \ - if (auto *sink = c.sinkRaw()) { \ + if (auto sink = c.sink()) { \ sink->name(n1, n2, n3); \ } \ } @@ -213,28 +214,15 @@ SentryTurboModulePerfController::setEnabled(bool enabled) noexcept void SentryTurboModulePerfController::setSink(std::shared_ptr sink) noexcept { - // Hold the mutex while we mutate both the owning `shared_ptr` and the - // hot-path raw pointer so any concurrent `sink()` reader observes a - // consistent pair. The raw pointer is published *after* the owning - // reference under release ordering so a reader that observes the new - // pointer also observes the new owning ref it points into. std::lock_guard lock(sink_mutex_); - ISentryTurboModulePerfSink *raw = sink.get(); - sink_owner_ = std::move(sink); - sink_cache_.store(raw, std::memory_order_release); + sink_ = std::move(sink); } std::shared_ptr SentryTurboModulePerfController::sink() const noexcept { std::lock_guard lock(sink_mutex_); - return sink_owner_; -} - -ISentryTurboModulePerfSink * -SentryTurboModulePerfController::sinkRaw() const noexcept -{ - return sink_cache_.load(std::memory_order_acquire); + return sink_; } bool diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.h b/packages/core/cpp/SentryTurboModulePerfLogger.h index 467efecc7a..785e956a1b 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.h +++ b/packages/core/cpp/SentryTurboModulePerfLogger.h @@ -61,34 +61,18 @@ class SentryTurboModulePerfController { void install() noexcept; /// Swap the sink that receives forwarded callbacks. Pass `nullptr` to detach. - /// Thread-safe — takes the sink mutex to mirror the owning `shared_ptr` and - /// the hot-path raw pointer atomically. - /// - /// Lifetime contract: a sink installed via `setSink(s)` MUST remain valid - /// until either the controller is destroyed or a subsequent `setSink` call - /// has completed. Concretely — once the controller is enabled and a sink - /// is installed, callers must not destroy the sink (i.e. drop the last - /// `shared_ptr` reference to it elsewhere) while the controller may still - /// dispatch into it. In practice the SDK installs the sink once at init - /// and keeps it alive for the lifetime of the process, which is the case - /// this contract is tuned for; the hot path therefore reads the sink via - /// a lock-free atomic raw pointer for zero overhead per TurboModule call. + /// Thread-safe via `sink_mutex_`. void setSink(std::shared_ptr sink) noexcept; /// Read the currently installed sink, or `nullptr` if none. The returned - /// `shared_ptr` is captured atomically (under the sink mutex) so the caller - /// holds an owning reference even if a concurrent `setSink` swaps the sink. - /// Used by tests and by external code that needs strong-ownership semantics; - /// the perf-logger forwarder uses the lock-free `sinkRaw()` path instead. + /// `shared_ptr` is captured atomically (under the sink mutex) so the + /// caller holds an owning reference even if a concurrent `setSink` swaps + /// or detaches the sink while a callback is in flight. The forwarder + /// invokes this on every TurboModule callback that survives the + /// `isEnabled()` early-return; the mutex cost is therefore paid only + /// when tracking is opted in, never on the default-off path. std::shared_ptr sink() const noexcept; - /// Hot-path read: returns the currently installed sink as a raw pointer - /// without acquiring any lock. The pointer is only valid for the duration - /// of a single TurboModule callback, and only safe under the lifetime - /// contract documented on `setSink`. Returns `nullptr` when no sink is - /// installed. - ISentryTurboModulePerfSink *sinkRaw() const noexcept; - /// Runtime enable / disable. Defaults to `false`. When `false`, the logger /// fast-paths every callback to a single atomic load — no virtual dispatch, /// no sink lookup. This is the gate the public `enableTurboModuleTracking` @@ -102,22 +86,17 @@ class SentryTurboModulePerfController { std::atomic installed_ { false }; std::atomic enabled_ { false }; - // Sink storage uses a two-level design: - // - `sink_owner_` holds the owning `shared_ptr` and is protected by - // `sink_mutex_`. Mutated by `setSink`; read by `sink()` for callers - // that need strong-ownership semantics (tests, introspection). - // - `sink_cache_` is a lock-free atomic raw pointer mirror used by the - // hot path (`sinkRaw()`). It lets every forwarded TurboModule callback - // read the sink without acquiring a mutex, which the sink interface - // explicitly requires of the forwarder. - // - // The atomic-shared_ptr variants are either C++20 (`std::atomic>`) - // or deprecated free functions (`std::atomic_load(std::shared_ptr*)`); the - // mutex+atomic-raw-pointer combo gives us lock-free reads on the hot path - // without requiring C++20 across every supported RN toolchain. + // Sink storage. The owning `shared_ptr` is mutated by `setSink` and read + // by `sink()` under `sink_mutex_`. We considered a lock-free atomic raw + // pointer mirror for the hot path, but that introduces a use-after-free + // hazard when `setSink` drops the previous owning reference while a + // forwarder callback still holds the raw pointer. The mutex variant is + // ~50–80 ns per callback (one atomic + lock + `shared_ptr` copy) and + // only fires when `isEnabled()` returns true — i.e. only when the user + // has explicitly opted in via `enableTurboModuleTracking`. The default-off + // path stays at one atomic load. mutable std::mutex sink_mutex_; - std::shared_ptr sink_owner_; - std::atomic sink_cache_ { nullptr }; + std::shared_ptr sink_; }; } // namespace sentry::reactnative From 5c7013c88ec8d49edbb1ad5707027bef924466e6 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 15:40:44 +0200 Subject: [PATCH 10/22] fix(turbomodule,android): Honour lazy load contract on setEnabled(false) Cursor MEDIUM on #6307: `setEnabled(false)` was running\n`ensureNativeLibraryLoaded()` before the native call, so the default\n`enableTurboModuleTracking: false` path \u2014 which calls\n`setEnabled(false)` from `initNativeSdk` \u2014 still loaded\n`libsentry-tm-perf-logger.so`. That broke the documented lazy-load\ncontract (hosts that never opt in pay no native library cost).\n\nShort-circuit `setEnabled(false)` when the library has not yet been\nattempted: the native flag's default is already false, so loading the\nlibrary only to flip it to its default is pure waste. The contract\nstill holds for the dynamic case \u2014 if a previous `setEnabled(true)`\nloaded the library, a subsequent `setEnabled(false)` correctly\npropagates the disable to the native flag.\n\nAdded `setEnabledFalseDoesNotLoadNativeLibrary` to lock the contract\ndown: in the test JVM the load would fail, so the proxy is to assert\nthe `nativeUnavailable` latch stays clear after a single\n`setEnabled(false)` call. --- .../RNSentryTurboModulePerfTrackerTest.kt | 16 ++++++++++++++++ .../react/RNSentryTurboModulePerfTracker.java | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt index 143e960dd3..71e06d327b 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt @@ -66,6 +66,22 @@ class RNSentryTurboModulePerfTrackerTest { ) } + @Test + fun setEnabledFalseDoesNotLoadNativeLibrary() { + // The lazy-load contract: hosts that never opt in to + // `enableTurboModuleTracking` pay no shared-library cost. A bare + // `initNativeSdk` with the option absent or `false` calls + // `setEnabled(false)` from Java, and we expect this NOT to attempt + // `System.loadLibrary`. The proxy for "did we attempt the load?" is + // the `nativeUnavailable` latch — in the test JVM the load would + // fail, so if it ran we would see the latch tripped. + RNSentryTurboModulePerfTracker.setEnabled(false) + assertFalse( + "setEnabled(false) on a fresh tracker must not attempt to load the native library", + RNSentryTurboModulePerfTracker.isNativeUnavailableForTests(), + ) + } + @Test fun resetClearsTheLatch() { RNSentryTurboModulePerfTracker.setEnabled(true) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java index 75219ec14a..e4d87d626a 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java @@ -53,6 +53,15 @@ public static void setEnabled(boolean enabled) { if (nativeUnavailable.get()) { return; } + // If we are disabling and the library has not yet been loaded, there is + // nothing to disable: the native flag's default is already `false`. + // Loading the library only to flip it to its default would break the lazy + // load contract ("hosts that never opt in pay no native library cost") + // and reintroduce the cost on every `initNativeSdk` call regardless of + // whether the user opted in. + if (!enabled && !libraryLoadAttempted.get()) { + return; + } if (!ensureNativeLibraryLoaded()) { return; } From f765bc3d2c9e4102e647d53eb8d61f0109c531a8 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 22 Jun 2026 16:41:38 +0200 Subject: [PATCH 11/22] fix(android): Reject initNativeSdk promise on native init failure Mirrors the iOS behavior: SentryAndroid.init can throw at runtime (e.g.\non invalid DSN or option configuration), but the Android initNativeSdk\npreviously caught nothing and unconditionally resolved the promise as\ntrue. The JS caller would then proceed to make native API calls against\nan uninitialized SDK and observe confusing downstream failures.\n\nWrap RNSentryStart.startWithOptions in a try/catch and reject the\npromise with the underlying error, matching the iOS reject(@"SentryReactNative",\n...) contract. The TurboModule perf-logger toggle is also skipped on\nfailure so we don't claim React Native's perf-logger slot when no\nSentry SDK is around to consume the data.\n\nAlso move the 8.15.0 changelog entry for enableTurboModuleTracking into\nthe Unreleased section, since this PR is now targeting the next\nrelease, and shorten it per review feedback. --- CHANGELOG.md | 5 +++- .../io/sentry/react/RNSentryModuleImpl.java | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2168b8b91..8f49cf45e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Add `enableTurboModuleTracking` opt-in experimental option to enable Turbo Module performance tracking in the New Architecture ([#6307](https://github.com/getsentry/sentry-react-native/pull/6307)) + ### Fixes - Remove unused `React/RCTTextView.h` import that broke iOS builds on React Native 0.87, where the header was removed as part of the legacy architecture cleanup ([#6322](https://github.com/getsentry/sentry-react-native/pull/6322)) @@ -31,7 +35,6 @@ ### Features -- Add `enableTurboModuleTracking` opt-in experimental option to enable Turbo Module performance tracking in the New Architecture ([#6307](https://github.com/getsentry/sentry-react-native/pull/6307)) - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) - Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 21bcdc0d5e..c24da2fd44 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -193,15 +193,21 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { // Set the React context for the logger so it can forward logs to JS rnLogger.setReactContext(this.reactApplicationContext); - RNSentryStart.startWithOptions( - getApplicationContext(), - rnOptions, - getCurrentActivity(), - options -> { - // Use our custom logger that forwards to JS - options.setLogger(rnLogger); - }, - logger); + try { + RNSentryStart.startWithOptions( + getApplicationContext(), + rnOptions, + getCurrentActivity(), + options -> { + // Use our custom logger that forwards to JS + options.setLogger(rnLogger); + }, + logger); + } catch (Throwable e) { // NOPMD - mirror iOS reject-on-failure behavior + logger.log(SentryLevel.ERROR, "Failed to initialize Sentry Android SDK", e); + promise.reject("SentryReactNative", e.getMessage(), e); + return; + } // Toggle the TurboModule perf-logger sink based on the JS option. The // sink lazy-installs the native `NativeModulePerfLogger` on first enable; From 00b77a04334de474f77042f1fb65cb8d46177df4 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 23 Jun 2026 09:48:07 +0200 Subject: [PATCH 12/22] fix(turbomodule): Reconcile tracking flag on every initNativeSdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes from review: 1. Re-init bug: the native TurboModule perf controller is process-wide and not reset by closeNativeSdk. The previous code only forwarded the JS option when it was present and a boolean, so a sequence of init({enableTurboModuleTracking: true}) -> init({}) would leave tracking latched on indefinitely after a prior opt-in. Both iOS and Android now resolve the option to a concrete boolean (defaulting to false when the key is absent or non-boolean) and unconditionally call setEnabled, mirroring the JS-side default of false on every init. setEnabled(false) is cheap and never triggers the lazy install, so hosts that never opt in still pay zero native cost. 2. Stale JSDoc: the enableTurboModuleTracking docstring still claimed the native perf logger is installed at SDK load time. That changed with the lazy-load refactor — install() is now reached only on the first setEnabled(true). Updated the JSDoc to describe the actual behavior so consumers know the RN perf-logger slot stays untouched while the option is off. --- .../java/io/sentry/react/RNSentryModuleImpl.java | 16 ++++++++++------ packages/core/ios/RNSentry.mm | 13 +++++++++---- packages/core/src/js/options.ts | 13 ++++++++----- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index c24da2fd44..2c474986d6 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -215,13 +215,17 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { // successfully — otherwise we'd claim React Native's perf-logger slot // while no Sentry SDK is around to consume the data. // + // Always reconcile to a concrete boolean (defaulting to `false`) so a + // re-init that omits the key cannot leave a previous opt-in latched on: + // the native controller is process-wide and not reset by closeNativeSdk. // The explicit `ReadableType.Boolean` check guards against JS passing a - // non-boolean (number, string, null) for the option, which would crash - // `getBoolean` with `UnexpectedNativeTypeException`. - if (rnOptions.hasKey("enableTurboModuleTracking") - && rnOptions.getType("enableTurboModuleTracking") == ReadableType.Boolean) { - RNSentryTurboModulePerfTracker.setEnabled(rnOptions.getBoolean("enableTurboModuleTracking")); - } + // non-boolean (number, string, null), which would crash `getBoolean` + // with `UnexpectedNativeTypeException`. + final boolean enableTurboModuleTracking = + rnOptions.hasKey("enableTurboModuleTracking") + && rnOptions.getType("enableTurboModuleTracking") == ReadableType.Boolean + && rnOptions.getBoolean("enableTurboModuleTracking"); + RNSentryTurboModulePerfTracker.setEnabled(enableTurboModuleTracking); promise.resolve(true); } diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 291fe55069..6d057ad38e 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -171,11 +171,16 @@ - (NSMutableDictionary *)prepareOptions:(NSDictionary *)options // rejected `initNativeSdk` would still leave tracking on (and would // claim the perf-logger slot via lazy install) while no SDK is around to // receive the data. + // + // Always reconcile to a concrete boolean (defaulting to `0`) so a + // re-init that omits the key cannot leave a previous opt-in latched on: + // the native controller is process-wide and not reset by closeNativeSdk. + // `setEnabled(false)` is cheap and never triggers the lazy install, so + // the RN perf-logger slot stays untouched while the option is off. id enableTurboModuleTracking = [options objectForKey:@"enableTurboModuleTracking"]; - if ([enableTurboModuleTracking isKindOfClass:[NSNumber class]]) { - Sentry_SetTurboModuleTrackingEnabled( - [(NSNumber *)enableTurboModuleTracking boolValue] ? 1 : 0); - } + BOOL turboModuleTrackingEnabled = [enableTurboModuleTracking isKindOfClass:[NSNumber class]] && + [(NSNumber *)enableTurboModuleTracking boolValue]; + Sentry_SetTurboModuleTrackingEnabled(turboModuleTrackingEnabled ? 1 : 0); // RNSentryStart.startWithOptions already handles: // - Session tracking notification (SentryHybridSdkDidBecomeActive) diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index 16d1e8fba5..0ba2ffcd7f 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -297,11 +297,14 @@ export interface BaseReactNativeOptions { * Only takes effect on React Native New Architecture. On Old Architecture * this option is a no-op. * - * The native perf logger is always installed at SDK load time so we never - * miss the earliest module-create events; this flag only gates whether - * forwarded callbacks actually reach the Sentry sink. Off by default - * because the higher-level features building on top of this hook ship in - * follow-up releases. + * The native perf logger is installed lazily on the first opt-in: while + * this flag is off (the default), Sentry never claims React Native's + * `NativeModulePerfLogger` slot and incurs no native-library mapping + * cost. The first `initNativeSdk` call with `enableTurboModuleTracking: + * true` loads the native library, installs the logger, and starts + * forwarding callbacks to the Sentry sink. Off by default because the + * higher-level features building on top of this hook ship in follow-up + * releases. * * @default false * @experimental From 7a08fe4a2ea4cde8c2229d59202ee1ec02c61587 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 23 Jun 2026 10:47:36 +0200 Subject: [PATCH 13/22] chore(sample): Enable enableTurboModuleTracking in RN sample Turns on the experimental TurboModule perf logger in the React Native\nsample app so reviewers can capture sample events end-to-end. The\nexisting "Native Crash" button on the Errors tab already routes through\nthe wrapped RNSentry.nativeCrash TurboModule call, so pressing it now\nproduces a crash report tagged with contexts.turbo_module +\nturbo_module.name=RNSentry / turbo_module.method=nativeCrash.\n\nNo-op on the Old Architecture and on hosts that strip Sentry's native\nlibraries; setEnabled(true) lazily loads libsentry-tm-perf-logger.so on\nfirst opt-in. --- samples/react-native/src/App.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index e6e6971ea6..53fac2f998 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -171,6 +171,13 @@ Sentry.init({ // Note that options from JS are not passed to the native SDKs when initialized manually autoInitializeNativeSdk: shouldUseAutoStart(), enableMetrics: true, + // Opt in to the experimental TurboModule perf logger so any native crash + // happening inside a TurboModule call gets `contexts.turbo_module` and the + // `turbo_module.name` / `turbo_module.method` tags attached to the event. + // Trigger it by tapping the "Native Crash" button on the Errors tab — the + // resulting crash report will be tagged with `RNSentry.nativeCrash`. + // No-op on the Old Architecture. + enableTurboModuleTracking: true, }); function BottomTabsNavigator() { From 8a16f7debd47d6c8bc0a1ae1bc188e1541a2bf1f Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 23 Jun 2026 14:27:23 +0200 Subject: [PATCH 14/22] fix(turbomodule): Default TM tracker to isolation scope for native sync The TurboModule call tracker pushed context+tags onto getCurrentScope(),\nbut enableSyncToNative is wired only on the global and isolation scopes.\nUnder @sentry/core's async-context strategy getCurrentScope() can return\na forked scope that is neither \u2014 in which case the scope.setContext(\n'turbo_module', ...) call updates JS state only and never propagates to\nsentry-cocoa / sentry-java. As a result, a native crash captured during\na TurboModule invocation lost the contexts.turbo_module attribution and\nthe turbo_module.name / turbo_module.method tags it was supposed to\ncarry, defeating the whole point of turboModuleContextIntegration.\n\nDefault pushTurboModuleCall to the isolation scope instead. Isolation\nis always synced to native via enableSyncToNative, so the write reaches\nsentry-cocoa / sentry-java in time for the crash report; JS captures\nstill see the data because @sentry/core merges global + isolation +\ncurrent at event time. Tests can still pass an explicit scope.\n\nVerified end-to-end in the RN sample: triggering 'Native Crash' on the\nErrors tab now produces an issue with contexts.turbo_module = { name:\n'RNSentry', method: 'crash', kind: 'sync', \u2026 } and matching tags.\n\nAlso correct the App.tsx comment to reference the underlying\nRNSentry.crash TurboModule method (not RNSentry.nativeCrash, which is\nthe JS-side wrapper name). --- .../src/js/turbomodule/turboModuleTracker.ts | 22 +++++++++++----- .../turbomodule/turboModuleTracker.test.ts | 25 ++++++++++++++++++- samples/react-native/src/App.tsx | 2 +- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/core/src/js/turbomodule/turboModuleTracker.ts b/packages/core/src/js/turbomodule/turboModuleTracker.ts index b0436a07d1..25941b69cc 100644 --- a/packages/core/src/js/turbomodule/turboModuleTracker.ts +++ b/packages/core/src/js/turbomodule/turboModuleTracker.ts @@ -1,6 +1,6 @@ import type { Scope } from '@sentry/core'; -import { getCurrentScope } from '@sentry/core'; +import { getIsolationScope } from '@sentry/core'; /** * Describes a single TurboModule method invocation currently in flight. @@ -20,10 +20,15 @@ export interface TurboModuleCall { 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 the call's context+tags were written to. Defaults to the isolation + * scope because that's the one {@link enableSyncToNative} hooks: writes to + * any other scope (e.g. a forked current scope inside `withScope`) update + * JS-side state only and never reach sentry-cocoa / sentry-java, so a + * native crash captured during the call would lose the attribution. + * + * Pinned at push time so an async call that spans a scope switch 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; } @@ -96,7 +101,12 @@ export function pushTurboModuleCall(args: { kind: args.kind, startedAtMs: Date.now(), callId: nextCallId++, - scope: args.scope ?? getCurrentScope(), + // Default to the isolation scope: it's the one wired up to + // `enableSyncToNative`, so writes here propagate to the native SDKs and + // get serialised into crash reports. `getCurrentScope()` would be wrong + // here — it can return a forked scope (per async-context strategy) that + // sentry-cocoa / sentry-java never sees. + scope: args.scope ?? getIsolationScope(), }; // Atomic push: if `syncToScope` throws (e.g. a scope-sync hook calls into a diff --git a/packages/core/test/turbomodule/turboModuleTracker.test.ts b/packages/core/test/turbomodule/turboModuleTracker.test.ts index ceb6e381f1..67a800d10b 100644 --- a/packages/core/test/turbomodule/turboModuleTracker.test.ts +++ b/packages/core/test/turbomodule/turboModuleTracker.test.ts @@ -1,4 +1,4 @@ -import { Scope } from '@sentry/core'; +import { getIsolationScope, Scope } from '@sentry/core'; import { _resetTurboModuleTracker, @@ -185,6 +185,29 @@ describe('turboModuleTracker', () => { popTurboModuleCall(b); }); + it('defaults to the isolation scope so writes propagate via enableSyncToNative', () => { + // Regression test: previously defaulted to `getCurrentScope()`, which can + // return a forked scope that is neither global nor isolation. Only global + // and isolation are wired up to `enableSyncToNative`, so writes to a + // forked current scope never reach the native SDKs — and native crashes + // captured during a TurboModule call lose the `turbo_module` attribution. + const isolation = getIsolationScope(); + isolation.clear(); + + const id = pushTurboModuleCall({ name: 'RNSentry', method: 'crash', kind: 'sync' }); + + expect(isolation.getScopeData().contexts.turbo_module).toMatchObject({ + name: 'RNSentry', + method: 'crash', + call_id: id, + }); + expect(isolation.getScopeData().tags['turbo_module.name']).toBe('RNSentry'); + expect(isolation.getScopeData().tags['turbo_module.method']).toBe('crash'); + + popTurboModuleCall(id); + isolation.clear(); + }); + it('is a no-op when popping an unknown id', () => { const id = pushTurboModuleCall({ name: 'RNSentry', method: 'a', kind: 'sync', scope }); diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 53fac2f998..b2808a4914 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -175,7 +175,7 @@ Sentry.init({ // happening inside a TurboModule call gets `contexts.turbo_module` and the // `turbo_module.name` / `turbo_module.method` tags attached to the event. // Trigger it by tapping the "Native Crash" button on the Errors tab — the - // resulting crash report will be tagged with `RNSentry.nativeCrash`. + // resulting crash report will be tagged with `RNSentry.crash`. // No-op on the Old Architecture. enableTurboModuleTracking: true, }); From 482ac45ae9256d0f1ab057dc032e30d19b576e32 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 23 Jun 2026 14:33:56 +0200 Subject: [PATCH 15/22] fix(turbomodule,android): Close setEnabled enable/disable race The setEnabled() short-circuit `!enabled && !libraryLoadAttempted.get()`\nran *outside* the monitor that ensureNativeLibraryLoaded() acquires for\nthe lazy System.loadLibrary call. A second thread issuing setEnabled(\nfalse) while a first thread was still inside loadLibrary observed\nlibraryLoadAttempted == false (the first thread sets it only after the\nload completes), returned early, and left tracking latched on after the\nloader finished \u2014 the opposite of what the disabling caller asked for.\n\nMake setEnabled itself synchronized so the short-circuit and the lazy\nload share a single monitor. Concurrent enable/disable now resolves as\nlast-call-wins instead of dropping the disable.\n\nThe lock is contended only on the first few calls before the library\nfinishes loading; setEnabled is invoked once per initNativeSdk so the\noverhead is negligible. --- .../sentry/react/RNSentryTurboModulePerfTracker.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java index e4d87d626a..95dbec5e09 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java @@ -48,8 +48,16 @@ private RNSentryTurboModulePerfTracker() {} *

The first invocation lazily loads {@code libsentry-tm-perf-logger.so}; subsequent calls * reuse the already-loaded library. A missing {@code .so} (Old Architecture, stripped binary) * permanently latches the tracker into a no-op state. + * + *

{@code synchronized} so the {@code !enabled && !libraryLoadAttempted} short-circuit and the + * lazy {@code System.loadLibrary} run under the same monitor. Without this, a thread calling + * {@code setEnabled(false)} concurrently with another thread already loading the library could + * observe {@code libraryLoadAttempted == false}, return early, and leave tracking latched on + * after the loader finishes — the opposite of what the caller asked for. The lock is contended + * only on the first few calls before the library finishes loading; {@code setEnabled} is invoked + * once per {@code initNativeSdk} so the overhead is negligible. */ - public static void setEnabled(boolean enabled) { + public static synchronized void setEnabled(boolean enabled) { if (nativeUnavailable.get()) { return; } From 0ab27612104907296ec85775e87590964872c85f Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 23 Jun 2026 17:27:28 +0200 Subject: [PATCH 16/22] test(turbomodule): Mock getIsolationScope in wrapTurboModule tests CI was failing 2 tests in `wrapTurboModule.test.ts` ("tracker push\nthrows" and "tracker pop throws"): the spy on `scope.setContext` was\nnever fired, so `pushTurboModuleCall` never threw, so the diagnostic\n`warn` call the tests asserted on never happened.\n\nRoot cause: commit `fix(turbomodule): Default TM tracker to isolation\nscope for native sync` (`8a16f7de`) switched `pushTurboModuleCall`'s\ndefault scope from `getCurrentScope()` to `getIsolationScope()` and\nupdated `turboModuleTracker.test.ts` to mock the new entry point, but\nmissed the sibling `wrapTurboModule.test.ts` which still mocked only\n`getCurrentScope`. The wrapper therefore wrote context/tags onto the\nreal isolation scope, the test's mock\u2019d `scope.setContext` never\nfired, and the assertions about the diagnostic warn went un-met.\n\nMock both `getIsolationScope` and `getCurrentScope` so the test\nremains deterministic regardless of which scope the tracker walks.\nAll 1618 JS tests pass after the change. --- packages/core/test/turbomodule/wrapTurboModule.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/core/test/turbomodule/wrapTurboModule.test.ts b/packages/core/test/turbomodule/wrapTurboModule.test.ts index 37a4e6068a..8cbdecce6e 100644 --- a/packages/core/test/turbomodule/wrapTurboModule.test.ts +++ b/packages/core/test/turbomodule/wrapTurboModule.test.ts @@ -12,6 +12,13 @@ describe('wrapTurboModule', () => { _resetTurboModuleTracker(); _resetWrappedModules(); scope = new Scope(); + // `pushTurboModuleCall` defaults to `getIsolationScope()` (see commit + // `fix(turbomodule): Default TM tracker to isolation scope for native sync`), + // so the tracker writes context/tags onto whichever scope this returns. + // We also mock `getCurrentScope` for any code in the wrapper that may + // read it directly, to keep the test deterministic regardless of + // `@sentry/core`'s async-context strategy in the test environment. + jest.spyOn(SentryCore, 'getIsolationScope').mockReturnValue(scope); jest.spyOn(SentryCore, 'getCurrentScope').mockReturnValue(scope); }); From 2c42d1d2dee4187ac33ec50df56163e28bb97952 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 24 Jun 2026 09:40:02 +0200 Subject: [PATCH 17/22] fix(turbomodule): Swallow exceptions in noexcept install() to avoid std::terminate Cursor HIGH on #6307: `SentryTurboModulePerfController::install` is\ndeclared `noexcept`, but it calls `std::make_unique()`\n(can throw `std::bad_alloc`) and forwards to\n`facebook::react::TurboModulePerfLogger::enableLogging` (no exception\nguarantees). If either throws \u2014 most plausibly under memory pressure\nwhen a user opts in to TurboModule tracking \u2014 the runtime calls\n`std::terminate` and the host app dies, instead of gracefully degrading\nto tracking-off.\n\nWrap the allocation+install in a `try { } catch (...) { }` and roll\nback the `installed_` latch on failure so a later `setEnabled(true)`\ncan retry once memory pressure clears. The SDK keeps running; the\nworst-case observable effect is that tracking remained off when the\nuser opted in. That is strictly better than terminating the process.\n\n`setEnabled` is unchanged \u2014 it only writes atomics and calls `install`,\nwhich is now truly `noexcept` after this fix.\n\nThe other comment in this review pass (Sentry bot MEDIUM about a\nconcurrent enable/disable race in `RNSentryTurboModulePerfTracker.setEnabled`)\nwas already addressed in commit 482ac45a, which made `setEnabled`\n`synchronized` so the short-circuit and the lazy `System.loadLibrary`\nshare a single monitor. --- packages/core/cpp/SentryTurboModulePerfLogger.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.cpp b/packages/core/cpp/SentryTurboModulePerfLogger.cpp index fbffaecdef..a0ef2a2f78 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.cpp +++ b/packages/core/cpp/SentryTurboModulePerfLogger.cpp @@ -189,7 +189,18 @@ SentryTurboModulePerfController::install() noexcept if (!installed_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { return; } - facebook::react::TurboModulePerfLogger::enableLogging(std::make_unique()); + // `std::make_unique` can throw `std::bad_alloc` and the third-party + // `enableLogging` makes no exception guarantees. We are declared + // `noexcept`, so any escape here would call `std::terminate` and bring + // down the host app. Swallow instead: roll back the `installed_` latch + // so a future caller can retry once memory pressure (or whatever caused + // the failure) clears, and leave the SDK running with tracking off. + try { + facebook::react::TurboModulePerfLogger::enableLogging( + std::make_unique()); + } catch (...) { + installed_.store(false, std::memory_order_release); + } #endif } From edaffa0d6cbb5f6826d4b525de650ec1193a33a5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 24 Jun 2026 11:20:26 +0200 Subject: [PATCH 18/22] fix(turbomodule): Use real pbxproj UUIDs, reflow cpp clang-format Two small follow-ups on #6307: - Warden flagged the 23-character placeholder UUIDs I hand-rolled (`A1B2C3D4E5F600000000001` / `A1B2C3D4E5F600000000002`) in the RNSentryCocoaTester pbxproj when adding the new `RNSentryTurboModulePerfControllerTests.mm` source. Xcode expects exactly 24 uppercase hex characters; antonis confirmed. Replaced with real 24-character UUIDs (`2639D71D3BD04F17B0BAC987` / `E795057A6D534A80A9D06356`) across all four references (`PBXBuildFile`, `PBXFileReference`, `PBXSourcesBuildPhase`, and the group children). - clang-format violation on the wrapped `enableLogging(...)` call in the noexcept-install fix \u2014 `yarn fix:clang` realigns it. CI lint job (run 28083145588) now matches local output. Verified with `pod install` + `xcodebuild test` on the cocoa-tester target: all 7 `RNSentryTurboModulePerfControllerTests` pass. --- .../RNSentryCocoaTester.xcodeproj/project.pbxproj | 8 ++++---- packages/core/cpp/SentryTurboModulePerfLogger.cpp | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index ad0438d3d6..8c47e431b6 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -17,7 +17,7 @@ 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */; }; 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */; }; 33F58AD02977037D008F60EA /* RNSentryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.m */; }; - A1B2C3D4E5F600000000001 /* RNSentryTurboModulePerfControllerTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600000000002 /* RNSentryTurboModulePerfControllerTests.mm */; }; + 2639D71D3BD04F17B0BAC987 /* RNSentryTurboModulePerfControllerTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = E795057A6D534A80A9D06356 /* RNSentryTurboModulePerfControllerTests.mm */; }; AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */; }; B4DEB41739F14AA38202D4D4 /* RNSentryUriValidationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */; }; B5859A50A3E865EF5E61465A /* libPods-RNSentryCocoaTesterTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */; }; @@ -52,7 +52,7 @@ 33F58ACF2977037D008F60EA /* RNSentryTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNSentryTests.m; sourceTree = ""; }; 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryUriValidationTests.m; sourceTree = ""; }; 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNSentryCocoaTesterTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - A1B2C3D4E5F600000000002 /* RNSentryTurboModulePerfControllerTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RNSentryTurboModulePerfControllerTests.mm; sourceTree = ""; }; + E795057A6D534A80A9D06356 /* RNSentryTurboModulePerfControllerTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RNSentryTurboModulePerfControllerTests.mm; sourceTree = ""; }; E2321E7CFA55AB617247098E /* Pods-RNSentryCocoaTesterTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNSentryCocoaTesterTests.debug.xcconfig"; path = "Target Support Files/Pods-RNSentryCocoaTesterTests/Pods-RNSentryCocoaTesterTests.debug.xcconfig"; sourceTree = ""; }; F48F26542EA2A481008A185E /* RNSentryEmitNewFrameEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryEmitNewFrameEvent.h; path = ../ios/RNSentryEmitNewFrameEvent.h; sourceTree = SOURCE_ROOT; }; F48F26552EA2A4D4008A185E /* RNSentryFramesTrackerListener.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryFramesTrackerListener.h; path = ../ios/RNSentryFramesTrackerListener.h; sourceTree = SOURCE_ROOT; }; @@ -113,7 +113,7 @@ 33F58ACF2977037D008F60EA /* RNSentryTests.m */, 3339C4802D6625570088EB3A /* RNSentryUserTests.m */, 3E3742693F7643C2ADE1BDF2 /* RNSentryUriValidationTests.m */, - A1B2C3D4E5F600000000002 /* RNSentryTurboModulePerfControllerTests.mm */, + E795057A6D534A80A9D06356 /* RNSentryTurboModulePerfControllerTests.mm */, 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */, 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */, 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */, @@ -269,7 +269,7 @@ 33F58AD02977037D008F60EA /* RNSentryTests.m in Sources */, 3339C4812D6625570088EB3A /* RNSentryUserTests.m in Sources */, B4DEB41739F14AA38202D4D4 /* RNSentryUriValidationTests.m in Sources */, - A1B2C3D4E5F600000000001 /* RNSentryTurboModulePerfControllerTests.mm in Sources */, + 2639D71D3BD04F17B0BAC987 /* RNSentryTurboModulePerfControllerTests.mm in Sources */, 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */, 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */, 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */, diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.cpp b/packages/core/cpp/SentryTurboModulePerfLogger.cpp index a0ef2a2f78..7f9028752c 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.cpp +++ b/packages/core/cpp/SentryTurboModulePerfLogger.cpp @@ -196,8 +196,7 @@ SentryTurboModulePerfController::install() noexcept // so a future caller can retry once memory pressure (or whatever caused // the failure) clears, and leave the SDK running with tracking off. try { - facebook::react::TurboModulePerfLogger::enableLogging( - std::make_unique()); + facebook::react::TurboModulePerfLogger::enableLogging(std::make_unique()); } catch (...) { installed_.store(false, std::memory_order_release); } From e3bbd9dd1e8684d62288cfef523822bf91941409 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 24 Jun 2026 14:46:26 +0200 Subject: [PATCH 19/22] fix(turbomodule): RN 0.71 OldArch build, promise hang, parsing, install race Four fixes from PR #6307 review: 1. **antonis blocker** (RN 0.71.19 legacy Android matrix): the build failed with `CMake Error at CMakeLists.txt:21 (add_library): Target 'sentry-tm-perf-logger' links to target 'ReactAndroid::reactnative' but the target was not found.` even though my gradle conditional only declared `externalNativeBuild` under NewArch. The cause is AGP/CMake auto-detection of `CMakeLists.txt` at the module root. Move the file to `src/main/jni/CMakeLists.txt` (next to OnLoad.cpp) so it lives outside any auto-detected path; gradle now references it explicitly via `cmake { path "src/main/jni/CMakeLists.txt" }` and the Old Arch build no longer touches it. Verified locally by running `./gradlew :sentry_react-native:assembleRelease` from `performance-tests/TestAppSentry/android` (newArchEnabled=false): no `configureCMake` task runs, no .so is produced, build succeeds. 2. **Warden** (`RNSentryModuleImpl.java:229`): if `RNSentryTurboModulePerfTracker.setEnabled` threw anything other than the already-caught `UnsatisfiedLinkError` (e.g. `SecurityException` from `System.loadLibrary`, any `RuntimeException` from the JNI symbol), execution skipped past `promise.resolve(true)` and the JS-side `initNativeSdk` promise hung forever. Wrap the `setEnabled` call in its own `try/catch` that logs and continues \u2014 the SDK has already started by this point, so a tracking-toggle failure is non-fatal to init. 3. **Cursor** (cross-platform parsing inconsistency): iOS accepted any `NSNumber` as the option value (so JS numeric `1` enabled tracking), Android required `ReadableType.Boolean` (so JS `1` was ignored). Tighten iOS to match: only honour an NSNumber whose `objCType` is `@encode(BOOL)`, which is what RN's bridge produces for a real JS boolean. JS numbers and strings now consistently fail the check on both platforms. 4. **Sentry bot** (race in `SentryTurboModulePerfController::install`): the previous fix rolled back `installed_` on `enableLogging` failure so a later caller could retry. That introduced a race: a concurrent thread observing the brief `installed_ == true` window would skip its own install attempt, then the originating thread's rollback would put us in a state where every caller thought someone else installed but nobody actually did. Switch to sticky "install attempted" semantics \u2014 the latch never rolls back. A failed install during the user opt-in path leaves tracking off for the rest of the process, which is strictly better than a silent half-installed state. All builds + tests pass locally (`yarn lint`, samples on both arches, `RNSentryAndroidTester`, `RNSentryCocoaTester/RNSentryTurboModulePerfControllerTests`). --- packages/core/android/build.gradle | 9 +++++++-- .../io/sentry/react/RNSentryModuleImpl.java | 14 +++++++++++++- .../android/{ => src/main/jni}/CMakeLists.txt | 18 ++++++++++++------ .../core/cpp/SentryTurboModulePerfLogger.cpp | 19 +++++++++++++++---- packages/core/ios/RNSentry.mm | 12 ++++++++++-- 5 files changed, 57 insertions(+), 15 deletions(-) rename packages/core/android/{ => src/main/jni}/CMakeLists.txt (78%) diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 3fc1c00ba7..1d95610ba5 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -62,11 +62,16 @@ android { } // CMake is also gated on New Architecture: on Old Arch the .so is never - // built and `RNSentryPackage` catches the missing-library error. + // built and `RNSentryPackage` catches the missing-library error. The + // CMakeLists.txt lives under `src/main/jni/` (not the module root) so + // AGP's auto-detection cannot find it on Old Arch — if it did, the + // configure step would try to link `ReactAndroid::reactnative` which + // only ships with the New Architecture prefab AAR (this is the failure + // mode antonis hit on the RN 0.71.19 legacy matrix entry). if (isNewArchitectureEnabled()) { externalNativeBuild { cmake { - path "CMakeLists.txt" + path "src/main/jni/CMakeLists.txt" } } } diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 2c474986d6..f2e2e09a14 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -225,7 +225,19 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { rnOptions.hasKey("enableTurboModuleTracking") && rnOptions.getType("enableTurboModuleTracking") == ReadableType.Boolean && rnOptions.getBoolean("enableTurboModuleTracking"); - RNSentryTurboModulePerfTracker.setEnabled(enableTurboModuleTracking); + // Defensive: the tracker only catches `UnsatisfiedLinkError` itself, but + // `System.loadLibrary` can also raise `SecurityException` and the native + // method could throw arbitrary `RuntimeException`. An uncaught exception + // here would skip `promise.resolve(true)` and leave the JS-side + // `initNativeSdk` promise pending forever. The SDK has already started + // successfully by this point, so treat any tracking-toggle failure as + // non-fatal and just log it. + try { + RNSentryTurboModulePerfTracker.setEnabled(enableTurboModuleTracking); + } catch (Throwable t) { // NOPMD - tracking is best-effort, must not break init + logger.log( + SentryLevel.WARNING, "Failed to toggle TurboModule perf tracking: " + t.getMessage()); + } promise.resolve(true); } diff --git a/packages/core/android/CMakeLists.txt b/packages/core/android/src/main/jni/CMakeLists.txt similarity index 78% rename from packages/core/android/CMakeLists.txt rename to packages/core/android/src/main/jni/CMakeLists.txt index 09bcaf53ed..4f5e5403b2 100644 --- a/packages/core/android/CMakeLists.txt +++ b/packages/core/android/src/main/jni/CMakeLists.txt @@ -7,8 +7,13 @@ # This CMake target is wired up only when the consuming app is built with # React Native's New Architecture (the only mode where `TurboModulePerfLogger` # exists). The gradle script in `build.gradle` enables `externalNativeBuild` -# and `buildFeatures { prefab true }` exclusively when `newArchEnabled` is set, -# so this file is never invoked under Old Arch. +# and `buildFeatures { prefab true }` exclusively when `newArchEnabled` is +# set, so this file is never invoked under Old Arch. The file lives under +# `src/main/jni/` (rather than the module root) so AGP's CMakeLists +# auto-detection cannot pick it up on Old Arch builds where the gradle +# `externalNativeBuild` block is intentionally absent — a stray +# auto-detection there would attempt to link against `ReactAndroid::reactnative`, +# which only ships in the New Architecture prefab AAR. cmake_minimum_required(VERSION 3.13) project(sentry-tm-perf-logger CXX) @@ -17,18 +22,19 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) # Build the shared library from the shared C++ source (also compiled into -# `RNSentry.framework` on iOS) plus the Android-specific JNI hook. +# `RNSentry.framework` on iOS) plus the Android-specific JNI hook. Paths +# are relative to this file: `/src/main/jni/`. add_library( sentry-tm-perf-logger SHARED - ../cpp/SentryTurboModulePerfLogger.cpp - src/main/jni/OnLoad.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../../../cpp/SentryTurboModulePerfLogger.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/OnLoad.cpp ) target_include_directories( sentry-tm-perf-logger PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/../cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../../../cpp # ReactAndroid's prefab exposes # but not the it # transitively pulls in. Add the source tree's reactperflogger dir to diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.cpp b/packages/core/cpp/SentryTurboModulePerfLogger.cpp index 7f9028752c..9030a90114 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.cpp +++ b/packages/core/cpp/SentryTurboModulePerfLogger.cpp @@ -185,6 +185,18 @@ SentryTurboModulePerfController::install() noexcept // `compare_exchange_strong` makes the install idempotent across competing // threads: only the first caller transitions `installed_` from `false` to // `true`, and only that caller hands the logger off to React Native. + // + // We deliberately do NOT roll back `installed_` if `enableLogging` + // throws. An earlier revision did, with the intent of letting a later + // caller retry under memory pressure. That introduced a race: a + // concurrent thread observing `installed_ == true` would skip its own + // install attempt, then the originating thread would roll the flag + // back to `false`, ending up in a state where every caller thinks + // someone else handled the install but nobody actually did. Sticky + // "install attempted" semantics close the race and are an acceptable + // trade-off: a failed install during the user opt-in path leaves + // tracking off for the rest of the process lifetime, which is strictly + // better than a silent half-installed state. bool expected = false; if (!installed_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { return; @@ -192,13 +204,12 @@ SentryTurboModulePerfController::install() noexcept // `std::make_unique` can throw `std::bad_alloc` and the third-party // `enableLogging` makes no exception guarantees. We are declared // `noexcept`, so any escape here would call `std::terminate` and bring - // down the host app. Swallow instead: roll back the `installed_` latch - // so a future caller can retry once memory pressure (or whatever caused - // the failure) clears, and leave the SDK running with tracking off. + // down the host app. Swallow instead and leave the controller in the + // "install attempted" state per the comment above. try { facebook::react::TurboModulePerfLogger::enableLogging(std::make_unique()); } catch (...) { - installed_.store(false, std::memory_order_release); + // intentionally empty — see rationale above } #endif } diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 5acfa09141..457b41ff2e 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -176,9 +176,17 @@ - (NSMutableDictionary *)prepareOptions:(NSDictionary *)options // the native controller is process-wide and not reset by closeNativeSdk. // `setEnabled(false)` is cheap and never triggers the lazy install, so // the RN perf-logger slot stays untouched while the option is off. + // Strict type match — we only honour a real BOOL here. Android requires + // `ReadableType.Boolean` and we want the same cross-platform contract: + // a JS numeric `1` should NOT enable tracking. JS booleans cross the RN + // bridge as `NSNumber` with `objCType == "c"` (signed char, the BOOL + // encoding); JS numbers come through as `NSNumber` with `"i"` / `"d"` + // and would slip past a plain `isKindOfClass:[NSNumber class]` check. id enableTurboModuleTracking = [options objectForKey:@"enableTurboModuleTracking"]; - BOOL turboModuleTrackingEnabled = [enableTurboModuleTracking isKindOfClass:[NSNumber class]] && - [(NSNumber *)enableTurboModuleTracking boolValue]; + BOOL turboModuleTrackingIsBool = [enableTurboModuleTracking isKindOfClass:[NSNumber class]] + && strcmp([(NSNumber *)enableTurboModuleTracking objCType], @encode(BOOL)) == 0; + BOOL turboModuleTrackingEnabled + = turboModuleTrackingIsBool && [(NSNumber *)enableTurboModuleTracking boolValue]; Sentry_SetTurboModuleTrackingEnabled(turboModuleTrackingEnabled ? 1 : 0); // RNSentryStart.startWithOptions already handles: From 416006d812324a7f3f2227ec517aaef52e51d53c Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 24 Jun 2026 15:53:48 +0200 Subject: [PATCH 20/22] fix(turbomodule,ios): Use CFBooleanGetTypeID instead of @encode(BOOL) for option parsing Warden caught a real bug in my previous `enableTurboModuleTracking`\nparsing fix: on 64-bit iOS `BOOL` is `typedef bool BOOL`, so\n`@encode(BOOL)` expands to `"B"` \u2014 but `[NSNumber numberWithBool:YES]`\n(including every JS boolean crossing the RN bridge) always reports\n`objCType == "c"` for historical compatibility. The\n`strcmp([(NSNumber *)value objCType], @encode(BOOL))` check therefore\nnever matched on any modern iOS device, and\n`enableTurboModuleTracking: true` was a silent no-op on iOS.\n\nReplace with the canonical, toll-free-bridged check\n`CFGetTypeID == CFBooleanGetTypeID()`. Verified with a small repro\nthat `@YES` returns `true` and `@1` returns `false` from\n`CFBooleanGetTypeID()`.\n\nAlso extract the parsing into a testable class method\n(`+ [RNSentry turboModuleTrackingEnabledFromOptions:]`) and add 7 unit\ntests in `RNSentryTurboModulePerfControllerTests.mm` covering:\n\n - JS `true` \u2192 enabled (the bug)\n - JS `false` \u2192 disabled\n - JS `1` \u2192 disabled (cross-platform parity with Android's\n `ReadableType.Boolean`)\n - JS `0` \u2192 disabled\n - String \u2192 disabled\n - Missing key \u2192 disabled\n - `NSNull` \u2192 disabled\n\nDictionary literals are hoisted into locals inside each test because\n`XCTAssertTrue`/`Fals`'s macro expansion uses `catch(T)` in ObjC++ and\nthe parser otherwise chokes on the comma inside `@{ k : v, }`. --- .../RNSentryCocoaTesterTests/RNSentry+Test.h | 2 + .../RNSentryTurboModulePerfControllerTests.mm | 56 +++++++++++++++++++ packages/core/ios/RNSentry.mm | 40 +++++++++---- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentry+Test.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentry+Test.h index fb9538e115..42cc221162 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentry+Test.h +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentry+Test.h @@ -7,6 +7,8 @@ + (BOOL)captureReplayWithReturnValue; ++ (BOOL)turboModuleTrackingEnabledFromOptions:(NSDictionary *)options; + #if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path; #endif diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm index 666f367ac4..5569930957 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm @@ -18,6 +18,8 @@ #import "../../cpp/SentryTurboModulePerfLogger.h" #import "../../cpp/SentryTurboModulePerfSink.h" +#import "RNSentry+Test.h" +#import using sentry::reactnative::ISentryTurboModulePerfSink; using sentry::reactnative::SentryTurboModulePerfController; @@ -316,6 +318,60 @@ - (void)testSetEnabledTrueIsLazyInstallAndSticky XCTAssertTrue(SentryTurboModulePerfController::instance().isEnabled()); } +#pragma mark - Option parsing + +// Regression coverage for the bug where `strcmp(objCType, @encode(BOOL))` +// never matched on 64-bit iOS because `BOOL` is `bool` (encode "B") while +// boolean NSNumbers report objCType "c". A JS `true` MUST enable tracking; +// a numeric `1` MUST NOT. + +// Dictionary literals are hoisted into locals because XCTAssertTrue/False +// expand into a `catch (T)` clause in ObjC++ and the parser otherwise gets +// confused by the comma inside `@{ key : value, }`. + +- (void)testTurboModuleTrackingEnabledFromOptionsAcceptsTrueBool +{ + NSDictionary *options = @{ @"enableTurboModuleTracking" : @YES }; + XCTAssertTrue([RNSentry turboModuleTrackingEnabledFromOptions:options]); +} + +- (void)testTurboModuleTrackingEnabledFromOptionsRejectsFalseBool +{ + NSDictionary *options = @{ @"enableTurboModuleTracking" : @NO }; + XCTAssertFalse([RNSentry turboModuleTrackingEnabledFromOptions:options]); +} + +- (void)testTurboModuleTrackingEnabledFromOptionsRejectsNumericOne +{ + // Cross-platform parity with Android's `ReadableType.Boolean` check — + // a JS numeric `1` must not slip through as a truthy boolean here. + NSDictionary *options = @{ @"enableTurboModuleTracking" : @1 }; + XCTAssertFalse([RNSentry turboModuleTrackingEnabledFromOptions:options]); +} + +- (void)testTurboModuleTrackingEnabledFromOptionsRejectsNumericZero +{ + NSDictionary *options = @{ @"enableTurboModuleTracking" : @0 }; + XCTAssertFalse([RNSentry turboModuleTrackingEnabledFromOptions:options]); +} + +- (void)testTurboModuleTrackingEnabledFromOptionsRejectsString +{ + NSDictionary *options = @{ @"enableTurboModuleTracking" : @"true" }; + XCTAssertFalse([RNSentry turboModuleTrackingEnabledFromOptions:options]); +} + +- (void)testTurboModuleTrackingEnabledFromOptionsRejectsMissingKey +{ + XCTAssertFalse([RNSentry turboModuleTrackingEnabledFromOptions:@{ }]); +} + +- (void)testTurboModuleTrackingEnabledFromOptionsRejectsNSNull +{ + NSDictionary *options = @{ @"enableTurboModuleTracking" : [NSNull null] }; + XCTAssertFalse([RNSentry turboModuleTrackingEnabledFromOptions:options]); +} + @end // NOTE: end-to-end forwarding (RN's `TurboModulePerfLogger::moduleCreateStart` diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 457b41ff2e..6cc9e946f4 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -84,6 +84,32 @@ + (BOOL)requiresMainQueueSetup return NO; } +// Strict type match for the JS `enableTurboModuleTracking` option — we only +// honour a real boolean. Android requires `ReadableType.Boolean`, and this +// helper enforces the same cross-platform contract: a JS numeric `1` must +// NOT enable tracking. +// +// `CFBooleanGetTypeID` is the canonical, toll-free-bridged way to +// distinguish `@YES` from `@1`. Comparing `[num objCType]` against +// `@encode(BOOL)` does NOT work on 64-bit iOS: `BOOL` is `typedef bool` +// there, so `@encode(BOOL)` is `"B"`, but every `NSNumber` created from a +// boolean (including everything crossing the RN bridge) always reports +// `objCType == "c"` for historical compatibility. An earlier revision had +// that bug — the `strcmp` check never matched and tracking was a no-op on +// every modern iOS device. Centralising the check behind one method keeps +// the trap from coming back. ++ (BOOL)turboModuleTrackingEnabledFromOptions:(NSDictionary *)options +{ + id value = [options objectForKey:@"enableTurboModuleTracking"]; + if (![value isKindOfClass:[NSNumber class]]) { + return NO; + } + if (CFGetTypeID((__bridge CFTypeRef)value) != CFBooleanGetTypeID()) { + return NO; + } + return [(NSNumber *)value boolValue]; +} + - (instancetype)init { if (self = [super init]) { @@ -176,18 +202,8 @@ - (NSMutableDictionary *)prepareOptions:(NSDictionary *)options // the native controller is process-wide and not reset by closeNativeSdk. // `setEnabled(false)` is cheap and never triggers the lazy install, so // the RN perf-logger slot stays untouched while the option is off. - // Strict type match — we only honour a real BOOL here. Android requires - // `ReadableType.Boolean` and we want the same cross-platform contract: - // a JS numeric `1` should NOT enable tracking. JS booleans cross the RN - // bridge as `NSNumber` with `objCType == "c"` (signed char, the BOOL - // encoding); JS numbers come through as `NSNumber` with `"i"` / `"d"` - // and would slip past a plain `isKindOfClass:[NSNumber class]` check. - id enableTurboModuleTracking = [options objectForKey:@"enableTurboModuleTracking"]; - BOOL turboModuleTrackingIsBool = [enableTurboModuleTracking isKindOfClass:[NSNumber class]] - && strcmp([(NSNumber *)enableTurboModuleTracking objCType], @encode(BOOL)) == 0; - BOOL turboModuleTrackingEnabled - = turboModuleTrackingIsBool && [(NSNumber *)enableTurboModuleTracking boolValue]; - Sentry_SetTurboModuleTrackingEnabled(turboModuleTrackingEnabled ? 1 : 0); + Sentry_SetTurboModuleTrackingEnabled( + [RNSentry turboModuleTrackingEnabledFromOptions:options] ? 1 : 0); // RNSentryStart.startWithOptions already handles: // - Session tracking notification (SentryHybridSdkDidBecomeActive) From 3622bf6d65df98514e7abeea31e5f5495cf59ffd Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 24 Jun 2026 16:14:46 +0200 Subject: [PATCH 21/22] fix(turbomodule): Honest isEnabled() after install failure; clearer node fallback error Two follow-ups from latest review pass: - **Cursor MEDIUM** (`SentryTurboModulePerfLogger.cpp`): the previous sticky-install fix accidentally let `isEnabled()` lie. The flow was `setEnabled(true)` \u2192 `enabled_ = true` \u2192 `install()` \u2192 `enableLogging` throws \u2192 `installed_` stays sticky-true (no retry) \u2192 `isEnabled()` returns `true` even though RN never received the perf logger. Worse, a later `setEnabled(true)` would short-circuit on the sticky latch and the same lie persisted for the rest of the process. Split the latch into `installAttempted_` (sticky after the first try) and `installed_` (only `true` if `enableLogging` succeeded), and have `isEnabled()` AND user intent with actual install state. Now: a successful install \u2192 `isEnabled()` mirrors `enabled_`. A failed install \u2192 `isEnabled()` is permanently `false` for the process, even if the user keeps calling `setEnabled(true)`. This is strictly honest about what tracking can deliver and means tests and consumers stop seeing false-positive 'tracking is on' readings. - **Sentry bot MEDIUM** (`build.gradle`): `resolveReactNativeDir()` calls `providers.exec("node", ...).get()` at gradle configure time, which throws hard if `node` is not on PATH. Wrap with a clearer `GradleException` pointing at the `REACT_NATIVE_NODE_MODULES_DIR` ext property the host project can use to skip the node lookup. - **Warden** (RNSentry.mm BOOL parsing): the new comment was a snapshot of the bug that 416006d8 already fixed via `CFBooleanGetTypeID`. No code change needed beyond a reply pointing at that commit. --- packages/core/android/build.gradle | 27 ++++++-- .../core/cpp/SentryTurboModulePerfLogger.cpp | 61 +++++++++++-------- .../core/cpp/SentryTurboModulePerfLogger.h | 11 ++++ 3 files changed, 67 insertions(+), 32 deletions(-) diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 1d95610ba5..6730233d47 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -19,11 +19,28 @@ def resolveReactNativeDir() { if (override != null) { return file(override) } - def resolved = providers.exec { - workingDir = rootDir - commandLine("node", "--print", "require.resolve('react-native/package.json')") - }.standardOutput.asText.get().trim() - return file(resolved).parentFile + // Fall back to a `node --print require.resolve(...)` lookup. The exec + // happens at gradle configure time, which means it will throw + // unconditionally if `node` is not on the host's PATH (e.g. on CI + // images that haven't installed Node, or developers building Android + // without a JS toolchain). Catch and rethrow with a clear message + // pointing at the `REACT_NATIVE_NODE_MODULES_DIR` ext property the + // host project can set to skip the lookup entirely. + try { + def resolved = providers.exec { + workingDir = rootDir + commandLine("node", "--print", "require.resolve('react-native/package.json')") + }.standardOutput.asText.get().trim() + return file(resolved).parentFile + } catch (Exception e) { + throw new GradleException( + "[@sentry/react-native] Could not locate react-native via `node`. " + + "Either install Node and ensure it is on PATH, or set the project " + + "extension property `REACT_NATIVE_NODE_MODULES_DIR` (in your root " + + "`build.gradle`) to the absolute path of your react-native install. " + + "Underlying error: ${e.message}", + e) + } } apply plugin: 'com.android.library' diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.cpp b/packages/core/cpp/SentryTurboModulePerfLogger.cpp index 9030a90114..37f70c1fbe 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.cpp +++ b/packages/core/cpp/SentryTurboModulePerfLogger.cpp @@ -182,34 +182,35 @@ void SentryTurboModulePerfController::install() noexcept { #if SENTRY_TM_PERF_LOGGER_AVAILABLE - // `compare_exchange_strong` makes the install idempotent across competing - // threads: only the first caller transitions `installed_` from `false` to - // `true`, and only that caller hands the logger off to React Native. + // `compare_exchange_strong` on `installAttempted_` makes the install + // idempotent across competing threads and ensures sticky "tried once, + // never again" semantics. We split that flag from the actual + // `installed_` success bit so callers can tell the difference between + // "install ran and succeeded" and "install ran and failed". // - // We deliberately do NOT roll back `installed_` if `enableLogging` - // throws. An earlier revision did, with the intent of letting a later - // caller retry under memory pressure. That introduced a race: a - // concurrent thread observing `installed_ == true` would skip its own - // install attempt, then the originating thread would roll the flag - // back to `false`, ending up in a state where every caller thinks - // someone else handled the install but nobody actually did. Sticky - // "install attempted" semantics close the race and are an acceptable - // trade-off: a failed install during the user opt-in path leaves - // tracking off for the rest of the process lifetime, which is strictly - // better than a silent half-installed state. + // Sticky "attempted" semantics close a race that an earlier revision + // had with a roll-back-on-failure pattern: a concurrent thread that + // observed the brief "true" window would skip its own install attempt, + // then the originating thread would roll the flag back, ending up in a + // state where every caller thought someone else handled the install + // but nobody actually did. A failed install during the user opt-in + // path therefore leaves tracking off for the rest of the process + // lifetime; the JS-side `isEnabled()` reflects that accurately. bool expected = false; - if (!installed_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { + if (!installAttempted_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) { return; } // `std::make_unique` can throw `std::bad_alloc` and the third-party // `enableLogging` makes no exception guarantees. We are declared // `noexcept`, so any escape here would call `std::terminate` and bring - // down the host app. Swallow instead and leave the controller in the - // "install attempted" state per the comment above. + // down the host app. Catch and leave `installed_` false so subsequent + // `setEnabled(true)` calls observe the failure and do not report + // `isEnabled() == true` while no perf logger is actually wired up. try { facebook::react::TurboModulePerfLogger::enableLogging(std::make_unique()); + installed_.store(true, std::memory_order_release); } catch (...) { - // intentionally empty — see rationale above + // intentionally empty — `installed_` stays `false` } #endif } @@ -217,9 +218,9 @@ SentryTurboModulePerfController::install() noexcept void SentryTurboModulePerfController::setEnabled(bool enabled) noexcept { - // Publish the new flag *before* installing the logger so any callback RN - // fires synchronously from inside `enableLogging()` already sees - // `isEnabled() == true` and reaches the sink instead of being dropped by + // Publish the user's intent before installing so any callback RN fires + // synchronously from inside `enableLogging()` already sees + // `enabled_ == true` and reaches the sink instead of being dropped by // the fast-path. On disable, order does not matter — we never uninstall. enabled_.store(enabled, std::memory_order_release); @@ -232,6 +233,18 @@ SentryTurboModulePerfController::setEnabled(bool enabled) noexcept } } +bool +SentryTurboModulePerfController::isEnabled() const noexcept +{ + // Tracking is operational only when the user has opted in AND the + // underlying perf logger is actually registered with React Native. If + // an install attempt failed (e.g. `std::bad_alloc`), `installed_` + // stays `false` and we honestly report `isEnabled() == false` even + // when the user requested tracking on. That keeps tests and downstream + // consumers from believing data is flowing when no logger is wired up. + return enabled_.load(std::memory_order_acquire) && installed_.load(std::memory_order_acquire); +} + void SentryTurboModulePerfController::setSink(std::shared_ptr sink) noexcept { @@ -246,12 +259,6 @@ SentryTurboModulePerfController::sink() const noexcept return sink_; } -bool -SentryTurboModulePerfController::isEnabled() const noexcept -{ - return enabled_.load(std::memory_order_acquire); -} - } // namespace sentry::reactnative extern "C" { diff --git a/packages/core/cpp/SentryTurboModulePerfLogger.h b/packages/core/cpp/SentryTurboModulePerfLogger.h index 785e956a1b..24d8c8c839 100644 --- a/packages/core/cpp/SentryTurboModulePerfLogger.h +++ b/packages/core/cpp/SentryTurboModulePerfLogger.h @@ -83,7 +83,18 @@ class SentryTurboModulePerfController { private: SentryTurboModulePerfController() noexcept = default; + // `installAttempted_` is sticky-true after the first `install()` call, + // regardless of outcome — we never retry the install (see the comment + // in `install()` for the race rationale). `installed_` is only set to + // `true` if `enableLogging` succeeded, so callers can tell whether the + // perf logger actually made it into RN. + std::atomic installAttempted_ { false }; std::atomic installed_ { false }; + + // `enabled_` carries the user's most recent intent. `isEnabled()` ANDs + // it with `installed_` so a JS opt-in that lost a race against a failed + // install does not appear active when the sink will never receive + // anything. std::atomic enabled_ { false }; // Sink storage. The owning `shared_ptr` is mutated by `setSink` and read From 8834bc68cd9e65d7b71c35ef279e839a94d11578 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 24 Jun 2026 16:39:18 +0200 Subject: [PATCH 22/22] fix(turbomodule,android): Gate native build on RN >= 0.75 prefab availability Address the RN 0.71.19 legacy CI failure. Root cause: `dev-packages/e2e-tests/cli.mjs` has a long-standing bug: the check `if (env.RCT_NEW_ARCH_ENABLED)` is truthy for the string "0", so the legacy matrix entries (which set the env var to "0") actually flip `newArchEnabled=true` in the host app's `gradle.properties`. That has been silently masking new-arch coverage as legacy on every PR for ages; my CMake config is just the first thing that requires the `ReactAndroid::reactnative` prefab, which only exists in RN >= 0.75. So configure fails on RN 0.71.19 with: CMake Error at CMakeLists.txt:27 (add_library): Target "sentry-tm-perf-logger" links to target "ReactAndroid::reactnative" but the target was not found. Rather than touch the CI script (out of this PR's scope), gate our CMake config on the host's actual React Native version. The new `isReactNativePrefabAvailable()` helper: 1. requires `newArchEnabled=true` (same as before) 2. resolves the host's `react-native/package.json` (via the existing\n `resolveReactNativeDir()` helper, which honours\n `REACT_NATIVE_NODE_MODULES_DIR`) 3. returns true only when the major.minor is >= 0.75 Both `buildFeatures { prefab true }` and `externalNativeBuild { cmake\n... }` blocks are now keyed off this gate. On RN < 0.75 we skip the native build entirely; `RNSentryTurboModulePerfTracker.setEnabled` catches the missing `.so` via its existing `UnsatisfiedLinkError` latch, exactly as it already does on Old Architecture. Verified locally: - RN 0.86.0 + NewArch \u2192 prefab-available = true, 4 .so files in AAR - RN 0.71.11 \u2192 prefab-available = false, no .so files - Android unit tests \u2192 still pass --- packages/core/android/build.gradle | 53 ++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 6730233d47..484fe7900d 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -6,6 +6,40 @@ def isNewArchitectureEnabled() { return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true" } +// `libsentry-tm-perf-logger.so` links against React Native's `reactnative` +// prefab target, which only ships in the `ReactAndroid` prefab AAR shipped +// with RN 0.75 and newer. On older RN releases the target does not exist +// and a `find_package(ReactAndroid)` call would fail the CMake configure +// step before we ever get to the build. Gate the native build on the host +// app's `react-native` version so older legacy-arch matrix entries that +// (intentionally or not) end up with `newArchEnabled=true` do not pull in +// our CMake target. +def isReactNativePrefabAvailable() { + if (!isNewArchitectureEnabled()) { + return false + } + try { + def reactNativeDir = resolveReactNativeDir() + def packageJson = new File(reactNativeDir, "package.json") + if (!packageJson.exists()) { + return false + } + def version = new groovy.json.JsonSlurper().parse(packageJson).version as String + def parts = version.tokenize(".") + if (parts.size() < 2) { + return false + } + def major = parts[0].toInteger() + def minor = parts[1].toInteger() + // RN 0.75+ ships the `ReactAndroid::reactnative` prefab. Anything + // earlier is a legacy-only target as far as our native code is + // concerned. + return major > 0 || minor >= 75 + } catch (Exception ignored) { + return false + } +} + // Locate the consuming app's `react-native` install. ReactAndroid's prefab // AAR exposes `` but not the // `` it transitively `#include`s, @@ -63,7 +97,7 @@ android { // AGP gotcha. def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION def needsBuildConfig = agpVersion.tokenize('.')[0].toInteger() >= 8 - def needsPrefab = isNewArchitectureEnabled() + def needsPrefab = isReactNativePrefabAvailable() if (needsBuildConfig || needsPrefab) { buildFeatures { if (needsBuildConfig) { @@ -78,14 +112,13 @@ android { } } - // CMake is also gated on New Architecture: on Old Arch the .so is never - // built and `RNSentryPackage` catches the missing-library error. The - // CMakeLists.txt lives under `src/main/jni/` (not the module root) so - // AGP's auto-detection cannot find it on Old Arch — if it did, the - // configure step would try to link `ReactAndroid::reactnative` which - // only ships with the New Architecture prefab AAR (this is the failure - // mode antonis hit on the RN 0.71.19 legacy matrix entry). - if (isNewArchitectureEnabled()) { + // CMake is gated on the same prefab-availability heuristic as `prefab` + // above: on RN < 0.75 the `ReactAndroid::reactnative` target does not + // exist and `find_package(ReactAndroid)` would fail the configure step + // even when `newArchEnabled=true` is set in the host's gradle.properties. + // The CMakeLists.txt lives under `src/main/jni/` (not the module root) + // so AGP's auto-detection cannot find it when this block is absent. + if (isReactNativePrefabAvailable()) { externalNativeBuild { cmake { path "src/main/jni/CMakeLists.txt" @@ -107,7 +140,7 @@ android { } buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() - if (isNewArchitectureEnabled()) { + if (isReactNativePrefabAvailable()) { def reactNativeDir = resolveReactNativeDir() externalNativeBuild { cmake {