From 3b618c5977974bee2e6cec7028a891f7a6d954f8 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 17 Jun 2026 14:37:41 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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); } }