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