diff --git a/CHANGELOG.md b/CHANGELOG.md index eb72966759..8f49cf45e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Add `enableTurboModuleTracking` opt-in experimental option to enable Turbo Module performance tracking in the New Architecture ([#6307](https://github.com/getsentry/sentry-react-native/pull/6307)) + ### Fixes - Remove unused `React/RCTTextView.h` import that broke iOS builds on React Native 0.87, where the header was removed as part of the legacy architecture cleanup ([#6322](https://github.com/getsentry/sentry-react-native/pull/6322)) 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/RNSentry.podspec b/packages/core/RNSentry.podspec index 12eef5804d..0cc316a229 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/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..71e06d327b --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryTurboModulePerfTrackerTest.kt @@ -0,0 +1,96 @@ +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. + * + * 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. + */ +@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 setEnabledFalseDoesNotLoadNativeLibrary() { + // The lazy-load contract: hosts that never opt in to + // `enableTurboModuleTracking` pay no shared-library cost. A bare + // `initNativeSdk` with the option absent or `false` calls + // `setEnabled(false)` from Java, and we expect this NOT to attempt + // `System.loadLibrary`. The proxy for "did we attempt the load?" is + // the `nativeUnavailable` latch — in the test JVM the load would + // fail, so if it ran we would see the latch tripped. + RNSentryTurboModulePerfTracker.setEnabled(false) + assertFalse( + "setEnabled(false) on a fresh tracker must not attempt to load the native library", + RNSentryTurboModulePerfTracker.isNativeUnavailableForTests(), + ) + } + + @Test + fun resetClearsTheLatch() { + RNSentryTurboModulePerfTracker.setEnabled(true) + 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..666f367ac4 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTurboModulePerfControllerTests.mm @@ -0,0 +1,326 @@ +// 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); +} + +- (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` +// 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/CMakeLists.txt b/packages/core/android/CMakeLists.txt new file mode 100644 index 0000000000..09bcaf53ed --- /dev/null +++ b/packages/core/android/CMakeLists.txt @@ -0,0 +1,65 @@ +# 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 +) + +# 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. diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 69d9c63b66..3fc1c00ba7 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' @@ -19,10 +39,35 @@ 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 + } + } + } + + // CMake is also gated on New Architecture: on Old Arch the .so is never + // built and `RNSentryPackage` catches the missing-library error. + if (isNewArchitectureEnabled()) { + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } } } @@ -39,6 +84,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..c24da2fd44 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -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,15 +193,35 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { // Set the React context for the logger so it can forward logs to JS rnLogger.setReactContext(this.reactApplicationContext); - RNSentryStart.startWithOptions( - getApplicationContext(), - rnOptions, - getCurrentActivity(), - options -> { - // Use our custom logger that forwards to JS - options.setLogger(rnLogger); - }, - logger); + try { + RNSentryStart.startWithOptions( + getApplicationContext(), + rnOptions, + getCurrentActivity(), + options -> { + // Use our custom logger that forwards to JS + options.setLogger(rnLogger); + }, + logger); + } catch (Throwable e) { // NOPMD - mirror iOS reject-on-failure behavior + logger.log(SentryLevel.ERROR, "Failed to initialize Sentry Android SDK", e); + promise.reject("SentryReactNative", e.getMessage(), e); + return; + } + + // Toggle the TurboModule perf-logger sink based on the JS option. The + // sink lazy-installs the native `NativeModulePerfLogger` on first enable; + // 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/android/src/main/java/io/sentry/react/RNSentryPackage.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryPackage.java index 1af2fe8c89..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 @@ -20,6 +20,12 @@ public class RNSentryPackage extends TurboReactPackage { private static final boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; + // `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 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..e4d87d626a --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java @@ -0,0 +1,133 @@ +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 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 {@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 + * {@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 we are disabling and the library has not yet been loaded, there is + // nothing to disable: the native flag's default is already `false`. + // Loading the library only to flip it to its default would break the lazy + // load contract ("hosts that never opt in pay no native library cost") + // and reintroduce the cost on every `initNativeSdk` call regardless of + // whether the user opted in. + if (!enabled && !libraryLoadAttempted.get()) { + return; + } + if (!ensureNativeLibraryLoaded()) { + return; + } + try { + nativeSetEnabled(enabled); + } catch (UnsatisfiedLinkError e) { + nativeUnavailable.set(true); + Log.i( + TAG, + "TurboModule perf-logger native symbol not found; tracking disabled: " + e.getMessage()); + } + } + + /** + * Attempts {@code System.loadLibrary} once and remembers the outcome. Returns {@code true} when + * the library is (or just became) available, {@code false} when it could not be loaded. + * + *

Synchronized so concurrent first callers block on the in-progress load instead of racing + * past it and hitting a phantom {@code UnsatisfiedLinkError} on {@code nativeSetEnabled} — which + * would then latch the tracker into a permanent no-op state for the lifetime of the process. The + * synchronization cost is paid at most a few times per process: once the load completes, every + * subsequent caller short-circuits on the early {@code libraryLoadAttempted} check before + * entering the monitor. + */ + private static boolean ensureNativeLibraryLoaded() { + if (libraryLoadAttempted.get()) { + return !nativeUnavailable.get(); + } + synchronized (RNSentryTurboModulePerfTracker.class) { + // Re-check under the monitor in case another thread completed the load while we were + // queued for the lock. + if (libraryLoadAttempted.get()) { + return !nativeUnavailable.get(); + } + try { + System.loadLibrary(LIB_NAME); + // Set the attempted flag last so any reader that observes it also sees the matching + // `nativeUnavailable` state established above. + libraryLoadAttempted.set(true); + return true; + } catch (UnsatisfiedLinkError e) { + // Expected on Old Arch and on hosts that strip Sentry's native libraries; the SDK keeps + // working with only Java-side instrumentation. + nativeUnavailable.set(true); + libraryLoadAttempted.set(true); + Log.i( + TAG, + "lib" + + LIB_NAME + + ".so not loaded; TurboModule perf tracking unavailable: " + + e.getMessage()); + return false; + } + } + } + + private static native void nativeSetEnabled(boolean enabled); + + @TestOnly + public static boolean isNativeUnavailableForTests() { + return nativeUnavailable.get(); + } + + @TestOnly + public static void resetNativeUnavailableForTests() { + nativeUnavailable.set(false); + libraryLoadAttempted.set(false); + } +} 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..218b79c21b --- /dev/null +++ b/packages/core/android/src/main/jni/OnLoad.cpp @@ -0,0 +1,27 @@ +// Copyright (c) Sentry. All rights reserved. +// +// JNI bridge for the Sentry TurboModule perf-logger shared library. +// +// 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. +// +// 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" + +/// 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..fbffaecdef --- /dev/null +++ b/packages/core/cpp/SentryTurboModulePerfLogger.cpp @@ -0,0 +1,250 @@ +// 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 macros below let us keep this file readable. Without them we'd + // have ~30 near-identical method bodies; with them the surface fits on + // one screen and any divergence between RN's API and ours surfaces as + // a compile error rather than a silent drop. + // + // Each forwarder uses the owning `sink()` accessor: it acquires the + // sink mutex, copies the `shared_ptr`, and releases the lock before + // invoking the sink. That keeps the sink alive for the duration of + // the call regardless of a concurrent `setSink`. The mutex cost is + // only paid when `isEnabled()` returns true — when tracking is off + // (the default), the early return runs after a single atomic load. +# define SENTRY_FORWARD0(name) \ + void name() override \ + { \ + 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::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, + // and matches the cost model promised by the JSDoc default of `false`. + if (enabled) { + install(); + } +} + +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_; +} + +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..785e956a1b --- /dev/null +++ b/packages/core/cpp/SentryTurboModulePerfLogger.h @@ -0,0 +1,123 @@ +// 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. + /// + /// 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 via `sink_mutex_`. + void setSink(std::shared_ptr sink) noexcept; + + /// Read the currently installed sink, or `nullptr` if none. The returned + /// `shared_ptr` is captured atomically (under the sink mutex) so the + /// caller holds an owning reference even if a concurrent `setSink` swaps + /// or detaches the sink while a callback is in flight. The forwarder + /// invokes this on every TurboModule callback that survives the + /// `isEnabled()` early-return; the mutex cost is therefore paid only + /// when tracking is opted in, never on the default-off path. + std::shared_ptr sink() const noexcept; + + /// 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. The owning `shared_ptr` is mutated by `setSink` and read + // by `sink()` under `sink_mutex_`. We considered a lock-free atomic raw + // pointer mirror for the hot path, but that introduces a use-after-free + // hazard when `setSink` drops the previous owning reference while a + // forwarder callback still holds the raw pointer. The mutex variant is + // ~50–80 ns per callback (one atomic + lock + `shared_ptr` copy) and + // only fires when `isEnabled()` returns true — i.e. only when the user + // has explicitly opted in via `enableTurboModuleTracking`. The default-off + // path stays at one atomic load. + mutable std::mutex sink_mutex_; + std::shared_ptr sink_; +}; + +} // namespace sentry::reactnative + +#ifdef __cplusplus +extern "C" { +#endif + +/// 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`. +/// 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" +#endif diff --git a/packages/core/cpp/SentryTurboModulePerfSink.h b/packages/core/cpp/SentryTurboModulePerfSink.h new file mode 100644 index 0000000000..117952a40f --- /dev/null +++ b/packages/core/cpp/SentryTurboModulePerfSink.h @@ -0,0 +1,108 @@ +// 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..291fe55069 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -58,6 +58,11 @@ - (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; @@ -138,6 +143,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 +158,7 @@ - (NSMutableDictionary *)prepareOptions:(NSDictionary *)options RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { NSMutableDictionary *mutableOptions = [self prepareOptions:options]; + NSError *error = nil; [RNSentryStart startWithOptions:mutableOptions error:&error]; if (error != nil) { @@ -155,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/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. * 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;