diff --git a/CHANGELOG.md b/CHANGELOG.md index 47145a41d4..b83ab5ca76 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 ([#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/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/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/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 abc6d91e7a..291a15b0bc 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..a8e0303586 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTurboModulePerfTracker.java @@ -0,0 +1,62 @@ +package io.sentry.react; + +import android.util.Log; +import org.jetbrains.annotations.TestOnly; + +/** + * 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); + + @TestOnly + public static boolean isNativeUnavailableForTests() { + return nativeUnavailable; + } + + @TestOnly + public static void resetNativeUnavailableForTests() { + nativeUnavailable = 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..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. *