diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 1801c67605..a81fe8f594 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -71,6 +71,10 @@ jobs: - name: Gradle cache uses: gradle/gradle-build-action@v2 + - name: Run unit tests + working-directory: RNSentryAndroidTester + run: ./gradlew testDebugUnitTest + - name: Setup KVM shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 610a53a927..7b58ba6bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## Unreleased +### Features + +- Add automatic tracing of time to initial display for `react-navigation` ([#3588](https://github.com/getsentry/sentry-react-native/pull/3588)) + + When enabled the instrumentation will create TTID spans and measurements. + The TTID timestamp represent moment when the `react-navigation` screen + was rendered by the native code. + + ```javascript + const routingInstrumentation = new Sentry.ReactNavigationInstrumentation({ + enableTimeToInitialDisplay: true, + }); + + Sentry.init({ + integrations: [new Sentry.ReactNativeTracing({routingInstrumentation})], + }); + ``` + ### Fixes - Allow custom `sentryUrl` for Expo updates source maps uploads ([#3664](https://github.com/getsentry/sentry-react-native/pull/3664)) diff --git a/RNSentry.podspec b/RNSentry.podspec index 1d0fc2f919..e6e2164404 100644 --- a/RNSentry.podspec +++ b/RNSentry.podspec @@ -35,7 +35,7 @@ Pod::Spec.new do |s| s.dependency 'React-Core' s.dependency 'Sentry/HybridSDK', '8.21.0' - s.source_files = 'ios/**/*.{h,mm}' + s.source_files = 'ios/**/*.{h,m,mm}' s.public_header_files = 'ios/RNSentry.h' s.compiler_flags = other_cflags diff --git a/RNSentryAndroidTester/app/build.gradle b/RNSentryAndroidTester/app/build.gradle index bc8ce7c861..fc0a2b9462 100644 --- a/RNSentryAndroidTester/app/build.gradle +++ b/RNSentryAndroidTester/app/build.gradle @@ -44,6 +44,8 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'com.google.android.material:material:1.5.0' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.10.0' + testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' } diff --git a/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/rnsentryandroidtester/MapConverterTest.kt b/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/rnsentryandroidtester/RNSentryMapConverterTest.kt similarity index 71% rename from RNSentryAndroidTester/app/src/androidTest/java/io/sentry/rnsentryandroidtester/MapConverterTest.kt rename to RNSentryAndroidTester/app/src/androidTest/java/io/sentry/rnsentryandroidtester/RNSentryMapConverterTest.kt index 9ed11f3876..0f7b9fc456 100644 --- a/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/rnsentryandroidtester/MapConverterTest.kt +++ b/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/rnsentryandroidtester/RNSentryMapConverterTest.kt @@ -4,7 +4,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.facebook.react.bridge.Arguments import com.facebook.soloader.SoLoader -import io.sentry.react.MapConverter +import io.sentry.react.RNSentryMapConverter import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith @@ -27,82 +27,82 @@ class MapConverterTest { @Test fun convertsUnknownValueToNull() { - val actual = MapConverter.convertToWritable(Unknown()) + val actual = RNSentryMapConverter.convertToWritable(Unknown()) assertNull(actual) } @Test fun convertsFloatToDouble() { - val actual = MapConverter.convertToWritable(Float.MAX_VALUE) + val actual = RNSentryMapConverter.convertToWritable(Float.MAX_VALUE) assert(actual is Double) assertEquals(Float.MAX_VALUE.toDouble(), actual) } @Test fun convertsByteToInt() { - val actual = MapConverter.convertToWritable(Byte.MAX_VALUE) + val actual = RNSentryMapConverter.convertToWritable(Byte.MAX_VALUE) assert(actual is Int) assertEquals(Byte.MAX_VALUE.toInt(), actual) } @Test fun convertsShortToInt() { - val actual = MapConverter.convertToWritable(Short.MAX_VALUE) + val actual = RNSentryMapConverter.convertToWritable(Short.MAX_VALUE) assert(actual is Int) assertEquals(Short.MAX_VALUE.toInt(), actual) } @Test fun convertsLongToDouble() { - val actual = MapConverter.convertToWritable(Long.MAX_VALUE) + val actual = RNSentryMapConverter.convertToWritable(Long.MAX_VALUE) assertEquals(Long.MAX_VALUE.toDouble(), actual) } @Test fun convertsBigDecimalToDouble() { - val actual = MapConverter.convertToWritable(BigDecimal.TEN) + val actual = RNSentryMapConverter.convertToWritable(BigDecimal.TEN) assertEquals(BigDecimal.TEN.toDouble(), actual) } @Test fun convertsBigIntegerToDouble() { - val actual = MapConverter.convertToWritable(BigInteger.TEN) + val actual = RNSentryMapConverter.convertToWritable(BigInteger.TEN) assertEquals(BigInteger.TEN.toDouble(), actual) } @Test fun keepsNull() { - val actual = MapConverter.convertToWritable(null) + val actual = RNSentryMapConverter.convertToWritable(null) assertNull(actual) } @Test fun keepsBoolean() { - val actual = MapConverter.convertToWritable(true) + val actual = RNSentryMapConverter.convertToWritable(true) assertEquals(true, actual) } @Test fun keepsDouble() { - val actual = MapConverter.convertToWritable(Double.MAX_VALUE) + val actual = RNSentryMapConverter.convertToWritable(Double.MAX_VALUE) assertEquals(Double.MAX_VALUE, actual) } @Test fun keepsInteger() { - val actual = MapConverter.convertToWritable(Integer.MAX_VALUE) + val actual = RNSentryMapConverter.convertToWritable(Integer.MAX_VALUE) assertEquals(Integer.MAX_VALUE, actual) } @Test fun keepsString() { - val actual = MapConverter.convertToWritable("string") + val actual = RNSentryMapConverter.convertToWritable("string") assertEquals("string", actual) } @Test fun convertsMapWithUnknownValueKey() { - val actualMap = MapConverter.convertToWritable(mapOf("unknown" to Unknown())) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("unknown" to Unknown())) val expectedMap = Arguments.createMap(); expectedMap.putNull("unknown") assertEquals(expectedMap, actualMap) @@ -110,7 +110,7 @@ class MapConverterTest { @Test fun convertsMapWithNullKey() { - val actualMap = MapConverter.convertToWritable(mapOf("null" to null)) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("null" to null)) val expectedMap = Arguments.createMap(); expectedMap.putNull("null") assertEquals(expectedMap, actualMap) @@ -118,7 +118,7 @@ class MapConverterTest { @Test fun convertsMapWithBooleanKey() { - val actualMap = MapConverter.convertToWritable(mapOf("boolean" to true)) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("boolean" to true)) val expectedMap = Arguments.createMap(); expectedMap.putBoolean("boolean", true) assertEquals(expectedMap, actualMap) @@ -126,7 +126,7 @@ class MapConverterTest { @Test fun convertsMapWithDoubleKey() { - val actualMap = MapConverter.convertToWritable(mapOf("double" to Double.MAX_VALUE)) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("double" to Double.MAX_VALUE)) val expectedMap = Arguments.createMap(); expectedMap.putDouble("double", Double.MAX_VALUE) assertEquals(expectedMap, actualMap) @@ -134,7 +134,7 @@ class MapConverterTest { @Test fun convertsMapWithIntegerKey() { - val actualMap = MapConverter.convertToWritable(mapOf("integer" to Integer.MAX_VALUE)) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("integer" to Integer.MAX_VALUE)) val expectedMap = Arguments.createMap(); expectedMap.putInt("integer", Integer.MAX_VALUE) assertEquals(expectedMap, actualMap) @@ -142,7 +142,7 @@ class MapConverterTest { @Test fun convertsMapWithByteKey() { - val actualMap = MapConverter.convertToWritable(mapOf("byte" to Byte.MAX_VALUE)) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("byte" to Byte.MAX_VALUE)) val expectedMap = Arguments.createMap(); expectedMap.putInt("byte", Byte.MAX_VALUE.toInt()) assertEquals(expectedMap, actualMap) @@ -150,7 +150,7 @@ class MapConverterTest { @Test fun convertsMapWithShortKey() { - val actualMap = MapConverter.convertToWritable(mapOf("short" to Short.MAX_VALUE)) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("short" to Short.MAX_VALUE)) val expectedMap = Arguments.createMap(); expectedMap.putInt("short", Short.MAX_VALUE.toInt()) assertEquals(expectedMap, actualMap) @@ -158,7 +158,7 @@ class MapConverterTest { @Test fun convertsMapWithFloatKey() { - val actualMap = MapConverter.convertToWritable(mapOf("float" to Float.MAX_VALUE)) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("float" to Float.MAX_VALUE)) val expectedMap = Arguments.createMap(); expectedMap.putDouble("float", Float.MAX_VALUE.toDouble()) assertEquals(expectedMap, actualMap) @@ -166,7 +166,7 @@ class MapConverterTest { @Test fun convertsMapWithLongKey() { - val actualMap = MapConverter.convertToWritable(mapOf("long" to Long.MAX_VALUE)) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("long" to Long.MAX_VALUE)) val expectedMap = Arguments.createMap(); expectedMap.putDouble("long", Long.MAX_VALUE.toDouble()) assertEquals(expectedMap, actualMap) @@ -174,7 +174,7 @@ class MapConverterTest { @Test fun convertsMapWithInBigDecimalKey() { - val actualMap = MapConverter.convertToWritable(mapOf("big_decimal" to BigDecimal.TEN)) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("big_decimal" to BigDecimal.TEN)) val expectedMap = Arguments.createMap(); expectedMap.putDouble("big_decimal", BigDecimal.TEN.toDouble()) assertEquals(expectedMap, actualMap) @@ -182,7 +182,7 @@ class MapConverterTest { @Test fun convertsMapWithBigIntKey() { - val actualMap = MapConverter.convertToWritable(mapOf("big_int" to BigInteger.TEN)) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("big_int" to BigInteger.TEN)) val expectedMap = Arguments.createMap(); expectedMap.putDouble("big_int", BigInteger.TEN.toDouble()) assertEquals(expectedMap, actualMap) @@ -190,7 +190,7 @@ class MapConverterTest { @Test fun convertsMapWithStringKey() { - val actualMap = MapConverter.convertToWritable(mapOf("string" to "string")) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("string" to "string")) val expectedMap = Arguments.createMap(); expectedMap.putString("string", "string") assertEquals(expectedMap, actualMap) @@ -198,7 +198,7 @@ class MapConverterTest { @Test fun convertsMapWithListKey() { - val actualMap = MapConverter.convertToWritable(mapOf("list" to listOf())) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("list" to listOf())) val expectedMap = Arguments.createMap() val expectedArray = Arguments.createArray() expectedMap.putArray("list", expectedArray) @@ -207,7 +207,7 @@ class MapConverterTest { @Test fun convertsMapWithNestedMapKey() { - val actualMap = MapConverter.convertToWritable(mapOf("map" to mapOf())) + val actualMap = RNSentryMapConverter.convertToWritable(mapOf("map" to mapOf())) val expectedMap = Arguments.createMap() val expectedNestedMap = Arguments.createMap() expectedMap.putMap("map", expectedNestedMap) @@ -218,70 +218,70 @@ class MapConverterTest { fun convertsListOfBoolean() { val expected = Arguments.createArray() expected.pushBoolean(true) - assertEquals(expected, MapConverter.convertToWritable(listOf(true))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf(true))) } @Test fun convertsListOfDouble() { val expected = Arguments.createArray() expected.pushDouble(Double.MAX_VALUE) - assertEquals(expected, MapConverter.convertToWritable(listOf(Double.MAX_VALUE))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf(Double.MAX_VALUE))) } @Test fun convertsListOfFloat() { val expected = Arguments.createArray() expected.pushDouble(Float.MAX_VALUE.toDouble()) - assertEquals(expected, MapConverter.convertToWritable(listOf(Float.MAX_VALUE))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf(Float.MAX_VALUE))) } @Test fun convertsListOfInteger() { val expected = Arguments.createArray() expected.pushInt(Int.MAX_VALUE) - assertEquals(expected, MapConverter.convertToWritable(listOf(Int.MAX_VALUE))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf(Int.MAX_VALUE))) } @Test fun convertsListOfShort() { val expected = Arguments.createArray() expected.pushInt(Short.MAX_VALUE.toInt()) - assertEquals(expected, MapConverter.convertToWritable(listOf(Short.MAX_VALUE))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf(Short.MAX_VALUE))) } @Test fun convertsListOfByte() { val expected = Arguments.createArray() expected.pushInt(Byte.MAX_VALUE.toInt()) - assertEquals(expected, MapConverter.convertToWritable(listOf(Byte.MAX_VALUE))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf(Byte.MAX_VALUE))) } @Test fun convertsListOfLong() { val expected = Arguments.createArray() expected.pushDouble(Long.MAX_VALUE.toDouble()) - assertEquals(expected, MapConverter.convertToWritable(listOf(Long.MAX_VALUE))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf(Long.MAX_VALUE))) } @Test fun convertsListOfBigInt() { val expected = Arguments.createArray() expected.pushDouble(BigInteger.TEN.toDouble()) - assertEquals(expected, MapConverter.convertToWritable(listOf(BigInteger.TEN))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf(BigInteger.TEN))) } @Test fun convertsListOfBigDecimal() { val expected = Arguments.createArray() expected.pushDouble(BigDecimal.TEN.toDouble()) - assertEquals(expected, MapConverter.convertToWritable(listOf(BigDecimal.TEN))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf(BigDecimal.TEN))) } @Test fun convertsListOfString() { val expected = Arguments.createArray() expected.pushString("string") - assertEquals(expected, MapConverter.convertToWritable(listOf("string"))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf("string"))) } @Test @@ -290,12 +290,12 @@ class MapConverterTest { val expectedMap = Arguments.createMap() expectedMap.putString("map", "string") expected.pushMap(expectedMap) - assertEquals(expected, MapConverter.convertToWritable(listOf(mapOf("map" to "string")))) + assertEquals(expected, RNSentryMapConverter.convertToWritable(listOf(mapOf("map" to "string")))) } @Test fun convertsNestedLists() { - val actual = MapConverter.convertToWritable(listOf(listOf())) + val actual = RNSentryMapConverter.convertToWritable(listOf(listOf())) val expectedArray = Arguments.createArray() val expectedNestedArray = Arguments.createArray() expectedArray.pushArray(expectedNestedArray) @@ -304,7 +304,7 @@ class MapConverterTest { @Test fun convertsComplexMapCorrectly() { - val actual = MapConverter.convertToWritable(mapOf( + val actual = RNSentryMapConverter.convertToWritable(mapOf( "integer" to Integer.MAX_VALUE, "string" to "string1", "map" to mapOf( diff --git a/RNSentryAndroidTester/app/src/androidTest/resources/mockito-extensions/org.mockito.plugins.MockMaker b/RNSentryAndroidTester/app/src/androidTest/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..ca6ee9cea8 --- /dev/null +++ b/RNSentryAndroidTester/app/src/androidTest/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/RNSentryAndroidTester/app/src/test/java/com/swmansion/rnscreens/ScreenStackFragment.kt b/RNSentryAndroidTester/app/src/test/java/com/swmansion/rnscreens/ScreenStackFragment.kt new file mode 100644 index 0000000000..4113126fc8 --- /dev/null +++ b/RNSentryAndroidTester/app/src/test/java/com/swmansion/rnscreens/ScreenStackFragment.kt @@ -0,0 +1,7 @@ +package com.swmansion.rnscreens + +import androidx.fragment.app.Fragment + +class ScreenStackFragment(contentLayoutId: Int) : Fragment(contentLayoutId) { + +} \ No newline at end of file diff --git a/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt b/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt new file mode 100644 index 0000000000..1dca319f15 --- /dev/null +++ b/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReactFragmentLifecycleTracerTest.kt @@ -0,0 +1,169 @@ +package io.sentry.rnsentryandroidtester + +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.EventDispatcher +import com.swmansion.rnscreens.ScreenStackFragment +import io.sentry.ILogger +import io.sentry.android.core.BuildInfoProvider +import io.sentry.react.RNSentryReactFragmentLifecycleTracer +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(JUnit4::class) +class RNSentryReactFragmentLifecycleTracerTest { + + private var mockUIManager: MockedStatic? = null + + @After + fun after() { + mockUIManager?.close(); + } + + @Test + fun tracerAddsListenerForValidRNScreenFragment() { + val mockEventDispatcher = mock(); + mockUIManager(mockEventDispatcher); + + callOnFragmentViewCreated(mock(), mockScreenViewWithReactContext()) + verify(mockEventDispatcher, times(1)).addListener(any()) + } + + @Test + fun tracerDoesNotAddListenerForGenericFragment() { + val mockEventDispatcher = mock(); + mockUIManager(mockEventDispatcher); + + callOnFragmentViewCreated(mock(), mockScreenViewWithReactContext()) + verify(mockEventDispatcher, times(0)).addListener(any()) + } + + @Test + fun tracerDoesNotAddListenerForViewWithoutChild() { + val mockEventDispatcher = mock(); + mockUIManager(mockEventDispatcher); + + callOnFragmentViewCreated(mock(), mockScreenViewWithoutChild()) + verify(mockEventDispatcher, times(0)).addListener(any()) + } + + @Test + fun tracerDoesNotAddListenerForViewWithoutReactContext() { + val mockEventDispatcher = mock(); + mockUIManager(mockEventDispatcher); + + callOnFragmentViewCreated(mock(), mockScreenViewWithGenericContext()) + verify(mockEventDispatcher, times(0)).addListener(any()) + } + + @Test + fun tracerDoesNotAddListenerForViewWithNoId() { + val mockEventDispatcher = mock(); + mockUIManager(mockEventDispatcher); + + callOnFragmentViewCreated(mock(), mockScreenViewWithNoId()) + verify(mockEventDispatcher, times(0)).addListener(any()) + } + + @Test + fun tracerDoesNotAddListenerForViewWithoutEventDispatcher() { + mockUIManagerToReturnNullEventDispatcher(); + + callOnFragmentViewCreated(mock(), mockScreenViewWithGenericContext()) + } + + private fun callOnFragmentViewCreated(mockFragment: Fragment, mockView: View) { + createSutWith().onFragmentViewCreated( + mock(), + mockFragment, + mockView, + null, + ) + } + + private fun createSutWith(): RNSentryReactFragmentLifecycleTracer { + val logger: ILogger = mock() + val buildInfo = BuildInfoProvider(logger) + + return RNSentryReactFragmentLifecycleTracer( + buildInfo, + mock(), + logger + ) + } + + private fun mockScreenViewWithReactContext(): View { + val screenMock: View = mock() { + whenever(it.id).thenReturn(123) + whenever(it.context).thenReturn(mock()) + } + val mockView = mock { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(screenMock) + } + return mockView; + } + + private fun mockScreenViewWithGenericContext(): View { + val screenMock: View = mock() { + whenever(it.id).thenReturn(123) + whenever(it.context).thenReturn(mock()) + } + val mockView = mock { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(screenMock) + } + return mockView; + } + + private fun mockScreenViewWithNoId(): View { + val screenMock: View = mock() { + whenever(it.id).thenReturn(-1) + whenever(it.context).thenReturn(mock()) + } + val mockView = mock { + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(screenMock) + } + return mockView; + } + + private fun mockScreenViewWithoutChild(): View { + return mock { + whenever(it.childCount).thenReturn(0) + } + } + + private fun mockUIManager(mockEventDispatcher: EventDispatcher) { + mockUIManager = mockStatic(UIManagerHelper::class.java) + mockUIManager + ?.`when` { UIManagerHelper.getReactContext(any()) } + ?.thenReturn(mock()) + mockUIManager + ?.`when` { UIManagerHelper.getEventDispatcherForReactTag(any(), anyInt()) } + ?.thenReturn(mockEventDispatcher) + } + + private fun mockUIManagerToReturnNullEventDispatcher() { + mockUIManager = mockStatic(UIManagerHelper::class.java) + mockUIManager + ?.`when` { UIManagerHelper.getReactContext(any()) } + ?.thenReturn(mock()) + mockUIManager + ?.`when` { UIManagerHelper.getEventDispatcherForReactTag(any(), anyInt()) } + ?.thenReturn(null) + } +} diff --git a/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 717b989522..a71ea9ab91 100644 --- a/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -7,15 +7,22 @@ objects = { /* Begin PBXBuildFile section */ - 33F58AD02977037D008F60EA /* RNSentry+initNativeSdk.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentry+initNativeSdk.mm */; }; + 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; + 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; }; + 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.mm */; }; B5859A50A3E865EF5E61465A /* libPods-RNSentryCocoaTesterTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 1482D5685A340AB93348A43D /* Pods-RNSentryCocoaTesterTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNSentryCocoaTesterTests.release.xcconfig"; path = "Target Support Files/Pods-RNSentryCocoaTesterTests/Pods-RNSentryCocoaTesterTests.release.xcconfig"; sourceTree = ""; }; 3360898D29524164007C7730 /* RNSentryCocoaTesterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RNSentryCocoaTesterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 338739072A7D7D2800950DDD /* RNSentry+initNativeSdk.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentry+initNativeSdk.h"; sourceTree = ""; }; - 33F58ACF2977037D008F60EA /* RNSentry+initNativeSdk.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = "RNSentry+initNativeSdk.mm"; sourceTree = ""; }; + 338739072A7D7D2800950DDD /* RNSentryTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryTests.h; sourceTree = ""; }; + 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryFramesTrackerListenerTests.m; sourceTree = ""; }; + 33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryFramesTrackerListenerTests.h; sourceTree = ""; }; + 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryDependencyContainerTests.m; sourceTree = ""; }; + 33AFDFF22B8D15F600AAB120 /* RNSentryDependencyContainerTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryDependencyContainerTests.h; sourceTree = ""; }; + 33AFE0132B8F31AF00AAB120 /* RNSentryDependencyContainer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryDependencyContainer.h; path = ../ios/RNSentryDependencyContainer.h; sourceTree = ""; }; + 33F58ACF2977037D008F60EA /* RNSentryTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNSentryTests.mm; sourceTree = ""; }; 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNSentryCocoaTesterTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; /* End PBXFileReference section */ @@ -44,6 +51,7 @@ 3360896929524163007C7730 = { isa = PBXGroup; children = ( + 33AFE0122B8F319000AAB120 /* RNSentry */, 3360899029524164007C7730 /* RNSentryCocoaTesterTests */, 3360897329524163007C7730 /* Products */, 00D0AE33FB669AAC85D66F8D /* Pods */, @@ -62,12 +70,24 @@ 3360899029524164007C7730 /* RNSentryCocoaTesterTests */ = { isa = PBXGroup; children = ( - 33F58ACF2977037D008F60EA /* RNSentry+initNativeSdk.mm */, - 338739072A7D7D2800950DDD /* RNSentry+initNativeSdk.h */, + 33F58ACF2977037D008F60EA /* RNSentryTests.mm */, + 338739072A7D7D2800950DDD /* RNSentryTests.h */, + 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */, + 33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */, + 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */, + 33AFDFF22B8D15F600AAB120 /* RNSentryDependencyContainerTests.h */, ); path = RNSentryCocoaTesterTests; sourceTree = ""; }; + 33AFE0122B8F319000AAB120 /* RNSentry */ = { + isa = PBXGroup; + children = ( + 33AFE0132B8F31AF00AAB120 /* RNSentryDependencyContainer.h */, + ); + name = RNSentry; + sourceTree = ""; + }; E9CBAA4D06145A9DB2C82C1B /* Frameworks */ = { isa = PBXGroup; children = ( @@ -176,7 +196,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 33F58AD02977037D008F60EA /* RNSentry+initNativeSdk.mm in Sources */, + 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */, + 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */, + 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryDependencyContainerTests.h b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryDependencyContainerTests.h new file mode 100644 index 0000000000..9d98d08b4a --- /dev/null +++ b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryDependencyContainerTests.h @@ -0,0 +1,13 @@ +#import +#import +#import "SentryFramesTracker.h" + +@interface +SentrySDK (PrivateTests) +- (nullable SentryOptions *) options; +@end + +@interface SentryDependencyContainer : NSObject ++ (instancetype)sharedInstance; +@property (nonatomic, strong) SentryFramesTracker *framesTracker; +@end diff --git a/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryDependencyContainerTests.m b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryDependencyContainerTests.m new file mode 100644 index 0000000000..4bed149b72 --- /dev/null +++ b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryDependencyContainerTests.m @@ -0,0 +1,28 @@ +#import "RNSentryDependencyContainerTests.h" +#import +#import +#import +#import "RNSentryDependencyContainer.h" + +@interface RNSentryDependencyContainerTests : XCTestCase + +@end + +@implementation RNSentryDependencyContainerTests + +- (void)testRNSentryDependencyContainerInitializesFrameTracker +{ + XCTAssertNil([[RNSentryDependencyContainer sharedInstance] framesTrackerListener]); + + id sentryDependencyContainerMock = OCMClassMock([SentryDependencyContainer class]); + OCMStub(ClassMethod([sentryDependencyContainerMock sharedInstance])).andReturn(sentryDependencyContainerMock); + + id frameTrackerMock = OCMClassMock([SentryFramesTracker class]); + OCMStub([(SentryDependencyContainer*) sentryDependencyContainerMock framesTracker]).andReturn(frameTrackerMock); + + RNSentryEmitNewFrameEvent emitNewFrameEvent = ^(NSNumber *newFrameTimestampInSeconds) {}; + [[RNSentryDependencyContainer sharedInstance] initializeFramesTrackerListenerWith: emitNewFrameEvent]; + XCTAssertNotNil([[RNSentryDependencyContainer sharedInstance] framesTrackerListener]); +} + +@end diff --git a/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryFramesTrackerListenerTests.h b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryFramesTrackerListenerTests.h new file mode 100644 index 0000000000..9d98d08b4a --- /dev/null +++ b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryFramesTrackerListenerTests.h @@ -0,0 +1,13 @@ +#import +#import +#import "SentryFramesTracker.h" + +@interface +SentrySDK (PrivateTests) +- (nullable SentryOptions *) options; +@end + +@interface SentryDependencyContainer : NSObject ++ (instancetype)sharedInstance; +@property (nonatomic, strong) SentryFramesTracker *framesTracker; +@end diff --git a/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryFramesTrackerListenerTests.m b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryFramesTrackerListenerTests.m new file mode 100644 index 0000000000..2a3336fb25 --- /dev/null +++ b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryFramesTrackerListenerTests.m @@ -0,0 +1,69 @@ +#import "RNSentryFramesTrackerListenerTests.h" +#import +#import +#import +#import "RNSentryDependencyContainer.h" + +@interface RNSentryFramesTrackerListenerTests : XCTestCase + +@end + +@implementation RNSentryFramesTrackerListenerTests + +- (void)testRNSentryFramesTrackerCallsGivenEventEmitterOnNewFrame +{ + id sentryDependencyContainerMock = OCMClassMock([SentryDependencyContainer class]); + OCMStub(ClassMethod([sentryDependencyContainerMock sharedInstance])).andReturn(sentryDependencyContainerMock); + + id frameTrackerMock = OCMClassMock([SentryFramesTracker class]); + OCMStub([(SentryDependencyContainer*) sentryDependencyContainerMock framesTracker]).andReturn(frameTrackerMock); + + XCTestExpectation *blockExpectation = [self expectationWithDescription:@"Block Expectation"]; + + RNSentryEmitNewFrameEvent mockEventEmitter = ^(NSNumber *newFrameTimestampInSeconds) { + XCTAssertTrue([newFrameTimestampInSeconds isKindOfClass:[NSNumber class]], @"The variable should be of type NSNumber."); + [blockExpectation fulfill]; + }; + + RNSentryFramesTrackerListener* actualListener = [[RNSentryFramesTrackerListener alloc] initWithSentryFramesTracker:[[SentryDependencyContainer sharedInstance] framesTracker] + andEventEmitter: mockEventEmitter]; + + [actualListener framesTrackerHasNewFrame: [NSDate date]]; + [self waitForExpectationsWithTimeout:1.0 handler:nil]; +} + +- (void)testRNSentryFramesTrackerIsOneTimeListener +{ + id sentryDependencyContainerMock = OCMClassMock([SentryDependencyContainer class]); + OCMStub(ClassMethod([sentryDependencyContainerMock sharedInstance])).andReturn(sentryDependencyContainerMock); + + id frameTrackerMock = OCMClassMock([SentryFramesTracker class]); + OCMStub([(SentryDependencyContainer*) sentryDependencyContainerMock framesTracker]).andReturn(frameTrackerMock); + + RNSentryEmitNewFrameEvent mockEventEmitter = ^(NSNumber *newFrameTimestampInSeconds) {}; + + RNSentryFramesTrackerListener* actualListener = [[RNSentryFramesTrackerListener alloc] initWithSentryFramesTracker:[[SentryDependencyContainer sharedInstance] framesTracker] + andEventEmitter: mockEventEmitter]; + + [actualListener framesTrackerHasNewFrame: [NSDate date]]; + OCMVerify([frameTrackerMock removeListener:actualListener]); +} + +- (void)testRNSentryFramesTrackerAddsItselfAsListener +{ + id sentryDependencyContainerMock = OCMClassMock([SentryDependencyContainer class]); + OCMStub(ClassMethod([sentryDependencyContainerMock sharedInstance])).andReturn(sentryDependencyContainerMock); + + id frameTrackerMock = OCMClassMock([SentryFramesTracker class]); + OCMStub([(SentryDependencyContainer*) sentryDependencyContainerMock framesTracker]).andReturn(frameTrackerMock); + + RNSentryEmitNewFrameEvent mockEventEmitter = ^(NSNumber *newFrameTimestampInSeconds) {}; + + RNSentryFramesTrackerListener* actualListener = [[RNSentryFramesTrackerListener alloc] initWithSentryFramesTracker:[[SentryDependencyContainer sharedInstance] framesTracker] + andEventEmitter: mockEventEmitter]; + + [actualListener startListening]; + OCMVerify([frameTrackerMock addListener:actualListener]); +} + +@end diff --git a/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentry+initNativeSdk.h b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.h similarity index 100% rename from RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentry+initNativeSdk.h rename to RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.h diff --git a/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentry+initNativeSdk.mm b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm similarity index 99% rename from RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentry+initNativeSdk.mm rename to RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm index c8fb9625cb..615deb45c4 100644 --- a/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentry+initNativeSdk.mm +++ b/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm @@ -1,4 +1,4 @@ -#import "RNSentry+initNativeSdk.h" +#import "RNSentryTests.h" #import #import #import @@ -191,7 +191,7 @@ - (void)prepareNativeFrameMocksWithLocalSymbolication: (BOOL) debug id sentryDependencyContainerMock = OCMClassMock([SentryDependencyContainer class]); OCMStub(ClassMethod([sentryDependencyContainerMock sharedInstance])).andReturn(sentryDependencyContainerMock); - + id sentryBinaryImageInfoMockOne = OCMClassMock([SentryBinaryImageInfo class]); OCMStub([(SentryBinaryImageInfo*) sentryBinaryImageInfoMockOne address]).andReturn([@112233 unsignedLongLongValue]); OCMStub([sentryBinaryImageInfoMockOne name]).andReturn(@"testnameone"); diff --git a/android/src/main/java/io/sentry/react/MapConverter.java b/android/src/main/java/io/sentry/react/RNSentryMapConverter.java similarity index 99% rename from android/src/main/java/io/sentry/react/MapConverter.java rename to android/src/main/java/io/sentry/react/RNSentryMapConverter.java index 524c3c1a48..d7dec91770 100644 --- a/android/src/main/java/io/sentry/react/MapConverter.java +++ b/android/src/main/java/io/sentry/react/RNSentryMapConverter.java @@ -17,7 +17,7 @@ import io.sentry.SentryLevel; import io.sentry.android.core.AndroidLogger; -public class MapConverter { +public class RNSentryMapConverter { public static final String NAME = "RNSentry.MapConverter"; private static final ILogger logger = new AndroidLogger(NAME); diff --git a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index ff93e0c1e6..4c69337e2b 100644 --- a/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -13,6 +13,8 @@ import android.util.SparseIntArray; import androidx.core.app.FrameMetricsAggregator; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; import com.facebook.hermes.instrumentation.HermesSamplingProfiler; import com.facebook.react.bridge.Arguments; @@ -25,15 +27,14 @@ import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeArray; import com.facebook.react.bridge.WritableNativeMap; +import com.facebook.react.modules.core.DeviceEventManagerModule; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.BufferedInputStream; import java.io.BufferedReader; -import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileReader; import java.io.IOException; @@ -55,6 +56,7 @@ import io.sentry.Integration; import io.sentry.Sentry; import io.sentry.SentryDate; +import io.sentry.SentryDateProvider; import io.sentry.SentryEvent; import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; @@ -69,6 +71,7 @@ import io.sentry.android.core.InternalSentrySdk; import io.sentry.android.core.NdkIntegration; import io.sentry.android.core.SentryAndroid; +import io.sentry.android.core.SentryAndroidDateProvider; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.ViewHierarchyEventProcessor; import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; @@ -123,21 +126,53 @@ public class RNSentryModuleImpl { private String cacheDirPath = null; private ISentryExecutorService executorService = null; + private final @NotNull Runnable emitNewFrameEvent; + /** Max trace file size in bytes. */ private long maxTraceFileSize = 5 * 1024 * 1024; public RNSentryModuleImpl(ReactApplicationContext reactApplicationContext) { - packageInfo = getPackageInfo(reactApplicationContext); - this.reactApplicationContext = reactApplicationContext; + packageInfo = getPackageInfo(reactApplicationContext); + this.reactApplicationContext = reactApplicationContext; + this.emitNewFrameEvent = createEmitNewFrameEvent(); } private ReactApplicationContext getReactApplicationContext() { - return this.reactApplicationContext; + return this.reactApplicationContext; + } + + private @Nullable Activity getCurrentActivity() { + return this.reactApplicationContext.getCurrentActivity(); + } + + private @NotNull Runnable createEmitNewFrameEvent() { + final @NotNull SentryDateProvider dateProvider = new SentryAndroidDateProvider(); + + return () -> { + final SentryDate endDate = dateProvider.now(); + WritableMap event = Arguments.createMap(); + event.putDouble("newFrameTimestampInSeconds", endDate.nanoTimestamp() / 1e9); + getReactApplicationContext() + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit("rn_sentry_new_frame", event); + }; + } + + private void initFragmentInitialFrameTracking() { + final RNSentryReactFragmentLifecycleTracer fragmentLifecycleTracer = + new RNSentryReactFragmentLifecycleTracer(buildInfo, emitNewFrameEvent, logger); + + final @Nullable FragmentActivity fragmentActivity = (FragmentActivity) getCurrentActivity(); + if (fragmentActivity != null) { + final @Nullable FragmentManager supportFragmentManager = fragmentActivity.getSupportFragmentManager(); + if (supportFragmentManager != null) { + supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleTracer, true); + } + } } - private @Nullable - Activity getCurrentActivity() { - return this.reactApplicationContext.getCurrentActivity(); + public void initNativeReactNavigationNewFrameTracking(Promise promise) { + this.initFragmentInitialFrameTracking(); } public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { @@ -151,7 +186,7 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); options.setNativeSdkName(NATIVE_SDK_NAME); - options.setSdkVersion(sdkVersion); + options.setSdkVersion(sdkVersion); if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) { options.setDebug(true); @@ -262,6 +297,16 @@ public void crash() { throw new RuntimeException("TEST - Sentry Client Crash (only works in release mode)"); } + public void addListener(String _eventType) { + // Is must be defined otherwise the generated interface from TS won't be fulfilled + logger.log(SentryLevel.ERROR, "addListener of NativeEventEmitter can't be used on Android!"); + } + + public void removeListeners(double _id) { + // Is must be defined otherwise the generated interface from TS won't be fulfilled + logger.log(SentryLevel.ERROR, "removeListeners of NativeEventEmitter can't be used on Android!"); + } + public void fetchModules(Promise promise) { final AssetManager assets = this.getReactApplicationContext().getResources().getAssets(); try (final InputStream stream = @@ -780,7 +825,7 @@ public void fetchNativeDeviceContexts(Promise promise) { context, (SentryAndroidOptions) options, currentScope); - final @Nullable Object deviceContext = MapConverter.convertToWritable(serialized); + final @Nullable Object deviceContext = RNSentryMapConverter.convertToWritable(serialized); promise.resolve(deviceContext); } diff --git a/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java b/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java new file mode 100644 index 0000000000..d5780d6746 --- /dev/null +++ b/android/src/main/java/io/sentry/react/RNSentryReactFragmentLifecycleTracer.java @@ -0,0 +1,99 @@ +package io.sentry.react; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks; + +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; + +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.uimanager.UIManagerHelper; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.EventDispatcher; +import com.facebook.react.uimanager.events.EventDispatcherListener; + +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.NotNull; + +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.internal.util.FirstDrawDoneListener; + +public class RNSentryReactFragmentLifecycleTracer extends FragmentLifecycleCallbacks { + + private @NotNull final BuildInfoProvider buildInfoProvider; + private @NotNull final Runnable emitNewFrameEvent; + private @NotNull final ILogger logger; + + public RNSentryReactFragmentLifecycleTracer( + @NotNull BuildInfoProvider buildInfoProvider, + @NotNull Runnable emitNewFrameEvent, + @NotNull ILogger logger) { + this.buildInfoProvider = buildInfoProvider; + this.emitNewFrameEvent = emitNewFrameEvent; + this.logger = logger; + } + + @Override + public void onFragmentViewCreated( + @NotNull FragmentManager fm, + @NotNull Fragment f, + @NotNull View v, + @Nullable Bundle savedInstanceState) { + if (!("com.swmansion.rnscreens.ScreenStackFragment".equals(f.getClass().getCanonicalName()))) { + logger.log(SentryLevel.DEBUG, "Fragment is not a ScreenStackFragment, won't listen for the first draw."); + return; + } + + if (!(v instanceof ViewGroup)) { + logger.log(SentryLevel.WARNING, "Fragment view is not a ViewGroup, won't listen for the first draw."); + return; + } + + final ViewGroup viewGroup = (ViewGroup) v; + if (viewGroup.getChildCount() == 0) { + logger.log(SentryLevel.WARNING, "Fragment view has no children, won't listen for the first draw."); + return; + } + + final @Nullable View screen = viewGroup.getChildAt(0); + if (screen == null || !(screen.getContext() instanceof ReactContext)) { + logger.log(SentryLevel.WARNING, "Fragment view has no ReactContext, won't listen for the first draw."); + return; + } + + final int screenId = screen.getId(); + if (screenId == View.NO_ID) { + logger.log(SentryLevel.WARNING, "Screen has no id, won't listen for the first draw."); + return; + } + + final @Nullable EventDispatcher eventDispatcher = getEventDispatcherForReactTag(screen, screenId); + if (eventDispatcher == null) { + logger.log(SentryLevel.WARNING, "Screen has no event dispatcher, won't listen for the first draw."); + return; + } + + final @NotNull Runnable emitNewFrameEvent = this.emitNewFrameEvent; + eventDispatcher.addListener(new EventDispatcherListener() { + @Override + public void onEventDispatch(Event event) { + if ("com.swmansion.rnscreens.events.ScreenAppearEvent".equals(event.getClass().getCanonicalName())) { + eventDispatcher.removeListener(this); + FirstDrawDoneListener + .registerForNextDraw(v, emitNewFrameEvent, buildInfoProvider); + } + } + }); + } + + private static @Nullable EventDispatcher getEventDispatcherForReactTag(@NonNull View screen, int screenId) { + return UIManagerHelper.getEventDispatcherForReactTag( + UIManagerHelper.getReactContext(screen), + screenId); + } +} diff --git a/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/android/src/newarch/java/io/sentry/react/RNSentryModule.java index c67cc3ddd1..78dfa4fa58 100644 --- a/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -2,7 +2,6 @@ import androidx.annotation.NonNull; -import com.facebook.react.bridge.JavaScriptExecutorFactory; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; @@ -24,6 +23,21 @@ public String getName() { return RNSentryModuleImpl.NAME; } + @Override + public void addListener(String eventType) { + this.impl.addListener(eventType); + } + + @Override + public void removeListeners(double id) { + this.impl.removeListeners(id); + } + + @Override + public void initNativeReactNavigationNewFrameTracking(Promise promise) { + this.impl.initNativeReactNavigationNewFrameTracking(promise); + } + @Override public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { this.impl.initNativeSdk(rnOptions, promise); diff --git a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index ef478c7d5a..1a11e85711 100644 --- a/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -23,6 +23,21 @@ public String getName() { return RNSentryModuleImpl.NAME; } + @ReactMethod + public void addListener(String eventType) { + this.impl.addListener(eventType); + } + + @ReactMethod + public void removeListeners(double id) { + this.impl.removeListeners(id); + } + + @ReactMethod + public void initNativeReactNavigationNewFrameTracking(Promise promise) { + this.impl.initNativeReactNavigationNewFrameTracking(promise); + } + @ReactMethod public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { this.impl.initNativeSdk(rnOptions, promise); diff --git a/ios/RNSentry.h b/ios/RNSentry.h index 18d2ce5195..f07dee122e 100644 --- a/ios/RNSentry.h +++ b/ios/RNSentry.h @@ -5,6 +5,7 @@ #endif #import +#import #import #import @@ -21,7 +22,7 @@ SentrySDK (Private) @property (nonatomic, nullable, readonly, class) SentryOptions *options; @end -@interface RNSentry : NSObject +@interface RNSentry : RCTEventEmitter - (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options error:(NSError *_Nullable*_Nonnull)errorPointer; diff --git a/ios/RNSentry.mm b/ios/RNSentry.mm index 90ebeb93b8..aa9362cc93 100644 --- a/ios/RNSentry.mm +++ b/ios/RNSentry.mm @@ -33,6 +33,11 @@ #import "RNSentrySpec.h" #endif +#import "RNSentryEvents.h" +#import "RNSentryDependencyContainer.h" +#import "RNSentryFramesTrackerListener.h" +#import "RNSentryRNSScreen.h" + @interface SentryTraceContext : NSObject - (nullable instancetype)initWithDict:(NSDictionary *)dictionary; @end @@ -51,6 +56,7 @@ + (void)storeEnvelope:(SentryEnvelope *)envelope; @implementation RNSentry { bool sentHybridSdkDidBecomeActive; + bool hasListeners; } - (dispatch_queue_t)methodQueue @@ -182,6 +188,44 @@ - (void)setEventEnvironmentTag:(SentryEvent *)event event.tags = newTags; } +RCT_EXPORT_METHOD(initNativeReactNavigationNewFrameTracking:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + if ([[NSThread currentThread] isMainThread]) { + [RNSentryRNSScreen swizzleViewDidAppear]; + } else { + dispatch_async(dispatch_get_main_queue(), ^{ + [RNSentryRNSScreen swizzleViewDidAppear]; + }); + } + + [self initFramesTracking]; + resolve(nil); +} + +- (void)initFramesTracking { + RNSentryEmitNewFrameEvent emitNewFrameEvent = ^(NSNumber *newFrameTimestampInSeconds) { + if (self->hasListeners) { + [self sendEventWithName:RNSentryNewFrameEvent body:@{ @"newFrameTimestampInSeconds": newFrameTimestampInSeconds }]; + } + }; + [[RNSentryDependencyContainer sharedInstance] initializeFramesTrackerListenerWith: emitNewFrameEvent]; +} + +// Will be called when this module's first listener is added. +-(void)startObserving { + hasListeners = YES; +} + +// Will be called when this module's last listener is removed, or on dealloc. +-(void)stopObserving { + hasListeners = NO; +} + +- (NSArray *)supportedEvents { + return @[RNSentryNewFrameEvent]; +} + RCT_EXPORT_METHOD(fetchNativeSdkInfo:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { diff --git a/ios/RNSentryDependencyContainer.h b/ios/RNSentryDependencyContainer.h new file mode 100644 index 0000000000..4bc178a3e6 --- /dev/null +++ b/ios/RNSentryDependencyContainer.h @@ -0,0 +1,15 @@ +#import + +#import "RNSentryFramesTrackerListener.h" + +@interface RNSentryDependencyContainer : NSObject +SENTRY_NO_INIT + +@property (class, readonly, strong) RNSentryDependencyContainer* sharedInstance; + +@property (nonatomic, strong) RNSentryFramesTrackerListener *framesTrackerListener; + +- (void)initializeFramesTrackerListenerWith:(RNSentryEmitNewFrameEvent) eventEmitter; + +@end + diff --git a/ios/RNSentryDependencyContainer.m b/ios/RNSentryDependencyContainer.m new file mode 100644 index 0000000000..10cee0d8af --- /dev/null +++ b/ios/RNSentryDependencyContainer.m @@ -0,0 +1,32 @@ +#import "RNSentryDependencyContainer.h" +#import + +@implementation RNSentryDependencyContainer { + NSObject *sentryDependencyContainerLock; + } + ++ (instancetype)sharedInstance +{ + static RNSentryDependencyContainer *instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ instance = [[self alloc] init]; }); + return instance; +} + +- (instancetype)init +{ + if (self = [super init]) { + sentryDependencyContainerLock = [[NSObject alloc] init]; + } + return self; +} + +- (void)initializeFramesTrackerListenerWith:(RNSentryEmitNewFrameEvent)eventEmitter +{ + @synchronized(sentryDependencyContainerLock) { + _framesTrackerListener = [[RNSentryFramesTrackerListener alloc] initWithSentryFramesTracker:[[SentryDependencyContainer sharedInstance] framesTracker] + andEventEmitter: eventEmitter]; + } +} + +@end diff --git a/ios/RNSentryEvents.h b/ios/RNSentryEvents.h new file mode 100644 index 0000000000..ee9f5e2088 --- /dev/null +++ b/ios/RNSentryEvents.h @@ -0,0 +1,3 @@ +#import + +extern NSString *const RNSentryNewFrameEvent; diff --git a/ios/RNSentryEvents.m b/ios/RNSentryEvents.m new file mode 100644 index 0000000000..13e3669cdd --- /dev/null +++ b/ios/RNSentryEvents.m @@ -0,0 +1,3 @@ +#import "RNSentryEvents.h" + +NSString *const RNSentryNewFrameEvent = @"rn_sentry_new_frame"; diff --git a/ios/RNSentryFramesTrackerListener.h b/ios/RNSentryFramesTrackerListener.h new file mode 100644 index 0000000000..a3a9abcf89 --- /dev/null +++ b/ios/RNSentryFramesTrackerListener.h @@ -0,0 +1,17 @@ +#import +#import +#import + +typedef void (^RNSentryEmitNewFrameEvent)(NSNumber *newFrameTimestampInSeconds); + +@interface RNSentryFramesTrackerListener : NSObject + +- (instancetype)initWithSentryFramesTracker:(SentryFramesTracker *) framesTracker + andEventEmitter:(RNSentryEmitNewFrameEvent) emitNewFrameEvent; + +- (void) startListening; + +@property (strong, nonatomic) SentryFramesTracker *framesTracker; +@property (strong, nonatomic) RNSentryEmitNewFrameEvent emitNewFrameEvent; + +@end diff --git a/ios/RNSentryFramesTrackerListener.m b/ios/RNSentryFramesTrackerListener.m new file mode 100644 index 0000000000..cb581f909a --- /dev/null +++ b/ios/RNSentryFramesTrackerListener.m @@ -0,0 +1,30 @@ +#import "RNSentryFramesTrackerListener.h" + +@implementation RNSentryFramesTrackerListener + +- (instancetype)initWithSentryFramesTracker:(SentryFramesTracker *)framesTracker + andEventEmitter:(RNSentryEmitNewFrameEvent) emitNewFrameEvent; +{ + self = [super init]; + if (self) { + _framesTracker = framesTracker; + _emitNewFrameEvent = [emitNewFrameEvent copy]; + } + return self; +} + +- (void)framesTrackerHasNewFrame:(NSDate *)newFrameDate +{ + [_framesTracker removeListener:self]; + NSNumber *newFrameTimestampInSeconds = [NSNumber numberWithDouble:[newFrameDate timeIntervalSince1970]]; + + if (_emitNewFrameEvent) { + _emitNewFrameEvent(newFrameTimestampInSeconds); + } +} + +- (void)startListening { + [_framesTracker addListener:self]; +} + +@end diff --git a/ios/RNSentryRNSScreen.h b/ios/RNSentryRNSScreen.h new file mode 100644 index 0000000000..0a9268c248 --- /dev/null +++ b/ios/RNSentryRNSScreen.h @@ -0,0 +1,7 @@ +#import + +@interface RNSentryRNSScreen : NSObject + ++ (void)swizzleViewDidAppear; + +@end diff --git a/ios/RNSentryRNSScreen.m b/ios/RNSentryRNSScreen.m new file mode 100644 index 0000000000..7e47f46044 --- /dev/null +++ b/ios/RNSentryRNSScreen.m @@ -0,0 +1,26 @@ +#import +#import +#import + +#import "RNSentryRNSScreen.h" +#import "RNSentryDependencyContainer.h" + +@implementation RNSentryRNSScreen + ++ (void)swizzleViewDidAppear { + Class rnsscreenclass = NSClassFromString(@"RNSScreen"); + if (rnsscreenclass == nil) + { + return; + } + + SEL selector = NSSelectorFromString(@"viewDidAppear:"); + SentrySwizzleInstanceMethod(rnsscreenclass, selector, SentrySWReturnType(void), + SentrySWArguments(BOOL animated), SentrySWReplacement({ + [[[RNSentryDependencyContainer sharedInstance] framesTrackerListener] startListening]; + SentrySWCallOriginal(animated); + }), + SentrySwizzleModeOncePerClass, (void *)selector); +} + +@end diff --git a/samples/react-native/ios/sampleNewArchitecture/Info.plist b/samples/react-native/ios/sampleNewArchitecture/Info.plist index cf53e2f560..959d218aa8 100644 --- a/samples/react-native/ios/sampleNewArchitecture/Info.plist +++ b/samples/react-native/ios/sampleNewArchitecture/Info.plist @@ -2,10 +2,6 @@ - UIAppFonts - - Ionicons.ttf - CFBundleDevelopmentRegion en CFBundleDisplayName @@ -37,6 +33,10 @@ NSLocationWhenInUseUsageDescription + UIAppFonts + + Ionicons.ttf + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities diff --git a/samples/react-native/package.json b/samples/react-native/package.json index f0f899cce8..f183b8a5d6 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -19,6 +19,7 @@ "dependencies": { "@react-navigation/bottom-tabs": "^6.5.12", "@react-navigation/native": "^6.1.9", + "@react-navigation/native-stack": "^6.9.17", "@react-navigation/stack": "^6.3.20", "react": "18.2.0", "react-native": "0.73.2", diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index d8bc1b0806..ee05aedd5f 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -3,7 +3,7 @@ import { NavigationContainer, NavigationContainerRef, } from '@react-navigation/native'; -import { createStackNavigator } from '@react-navigation/stack'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; // Import the Sentry React Native SDK @@ -27,6 +27,7 @@ import Ionicons from 'react-native-vector-icons/Ionicons'; const reactNavigationInstrumentation = new Sentry.ReactNavigationInstrumentation({ routeChangeTimeoutMs: 500, // How long it will wait for the route change to complete. Default is 1000ms + enableTimeToInitialDisplay: true, }); Sentry.init({ @@ -53,6 +54,7 @@ Sentry.init({ idleTimeout: 5000, routingInstrumentation: reactNavigationInstrumentation, enableUserInteractionTracing: true, + ignoreEmptyBackNavigationTransactions: true, beforeNavigate: (context: Sentry.ReactNavigationTransactionContext) => { // Example of not sending a transaction for the screen with the name "Manual Tracker" if (context.data.route.name === 'ManualTracker') { @@ -100,48 +102,57 @@ Sentry.init({ enableSpotlight: true, }); -const Stack = createStackNavigator(); +const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); -const TabOneStack = () => { - return ( - - - - - - - - ); -}; +const TabOneStack = Sentry.withProfiler( + () => { + return ( + + + + + + + + ); + }, + { name: 'ErrorsTab' }, +); -const TabTwoStack = () => { - return ( - - - - - - - - - - - - - ); -}; +const TabTwoStack = Sentry.withProfiler( + () => { + return ( + + + + + + + + + + + + + ); + }, + { name: 'PerformanceTab' }, +); function BottomTabs() { const navigation = React.useRef>(null); diff --git a/samples/react-native/src/Screens/PerformanceScreen.tsx b/samples/react-native/src/Screens/PerformanceScreen.tsx index bb41c1a494..c768d4b540 100644 --- a/samples/react-native/src/Screens/PerformanceScreen.tsx +++ b/samples/react-native/src/Screens/PerformanceScreen.tsx @@ -10,6 +10,7 @@ import { import { StackNavigationProp } from '@react-navigation/stack'; import { CommonActions } from '@react-navigation/native'; +import * as Sentry from '@sentry/react-native'; interface Props { navigation: StackNavigationProp; @@ -67,12 +68,14 @@ const PerformanceScreen = (props: Props) => { ); }; -const Button = (props: ButtonProps) => ( - <> - - - -); +const Button = (props: ButtonProps) => { + return ( + + + + + ); +}; const styles = StyleSheet.create({ welcomeTitle: { @@ -99,4 +102,4 @@ const styles = StyleSheet.create({ }, }); -export default PerformanceScreen; +export default Sentry.withProfiler(PerformanceScreen); diff --git a/samples/react-native/yarn.lock b/samples/react-native/yarn.lock index 4a62d56743..602f5f6181 100644 --- a/samples/react-native/yarn.lock +++ b/samples/react-native/yarn.lock @@ -2822,6 +2822,14 @@ resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-1.3.22.tgz#37e25e46ca4715049795471056a9e7e58ac4a14e" integrity sha512-HYKucs0TwQT8zMvgoZbJsY/3sZfzeP8Dk9IDv4agst3zlA7ReTx4+SROCG6VGC7JKqBCyQykHIwkSwxhapoc+Q== +"@react-navigation/native-stack@^6.9.17": + version "6.9.17" + resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-6.9.17.tgz#4fc370b14be07296423ae8c00940fb002c6001b5" + integrity sha512-X8p8aS7JptQq7uZZNFEvfEcPf6tlK4PyVwYDdryRbG98B4bh2wFQYMThxvqa+FGEN7USEuHdv2mF0GhFKfX0ew== + dependencies: + "@react-navigation/elements" "^1.3.21" + warn-once "^0.1.0" + "@react-navigation/native@^6.1.9": version "6.1.9" resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-6.1.9.tgz#8ef87095cd9c2ed094308c726157c7f6fc28796e" diff --git a/src/js/NativeRNSentry.ts b/src/js/NativeRNSentry.ts index 99eb313438..b76ce28485 100644 --- a/src/js/NativeRNSentry.ts +++ b/src/js/NativeRNSentry.ts @@ -7,6 +7,8 @@ import type { UnsafeObject } from './utils/rnlibrariesinterface'; // There has to be only one interface and it has to be named `Spec` // Only extra allowed definitions are types (probably codegen bug) export interface Spec extends TurboModule { + addListener: (eventType: string) => void; + removeListeners: (id: number) => void; addBreadcrumb(breadcrumb: UnsafeObject): void; captureEnvelope( bytes: string, @@ -41,6 +43,7 @@ export interface Spec extends TurboModule { }; fetchNativePackageName(): string | undefined | null; fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | undefined | null; + initNativeReactNavigationNewFrameTracking(): Promise; } export type NativeStackFrame = { diff --git a/src/js/tracing/reactnativetracing.ts b/src/js/tracing/reactnativetracing.ts index 72f51bbf60..999f3d3a00 100644 --- a/src/js/tracing/reactnativetracing.ts +++ b/src/js/tracing/reactnativetracing.ts @@ -2,7 +2,7 @@ import type { RequestInstrumentationOptions } from '@sentry/browser'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from '@sentry/browser'; import type { Hub, IdleTransaction, Transaction } from '@sentry/core'; -import { getActiveTransaction, getCurrentHub, startIdleTransaction } from '@sentry/core'; +import { getActiveTransaction, getCurrentHub, setMeasurement, startIdleTransaction } from '@sentry/core'; import type { Event, EventProcessor, @@ -437,6 +437,17 @@ export class ReactNativeTracing implements Integration { transaction.startTimestamp = appStartTimeSeconds; + const maybeTtidSpan = transaction.spanRecorder?.spans.find(span => span.op === 'ui.load.initial_display'); + if (maybeTtidSpan) { + maybeTtidSpan.startTimestamp = appStartTimeSeconds; + maybeTtidSpan.endTimestamp && + setMeasurement( + 'time_to_initial_display', + (maybeTtidSpan.endTimestamp - appStartTimeSeconds) * 1000, + 'millisecond', + ); + } + const op = appStart.isColdStart ? APP_START_COLD_OP : APP_START_WARM_OP; transaction.startChild({ description: appStart.isColdStart ? 'Cold App Start' : 'Warm App Start', @@ -536,7 +547,12 @@ export class ReactNativeTracing implements Integration { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access transaction.data?.route?.hasBeenSeen && (!transaction.spanRecorder || - transaction.spanRecorder.spans.filter(span => span.spanId !== transaction.spanId).length === 0) + transaction.spanRecorder.spans.filter( + span => + span.spanId !== transaction.spanId && + span.op !== 'ui.load.initial_display' && + span.op !== 'navigation.processing', + ).length === 0) ) { logger.log( '[ReactNativeTracing] Not sampling transaction as route has been seen before. Pass ignoreEmptyBackNavigationTransactions = false to disable this feature.', diff --git a/src/js/tracing/reactnavigation.ts b/src/js/tracing/reactnavigation.ts index 1504eb45cd..7e882b1a8f 100644 --- a/src/js/tracing/reactnavigation.ts +++ b/src/js/tracing/reactnavigation.ts @@ -1,8 +1,12 @@ /* eslint-disable max-lines */ -import type { Transaction as TransactionType, TransactionContext } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { getActiveSpan, setMeasurement, spanToJSON, startInactiveSpan } from '@sentry/core'; +import type { Span, Transaction as TransactionType, TransactionContext } from '@sentry/types'; +import { logger, timestampInSeconds } from '@sentry/utils'; +import type { NewFrameEvent } from '../utils/sentryeventemitter'; +import { type SentryEventEmitter, createSentryEventEmitter, NewFrameEventName } from '../utils/sentryeventemitter'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; +import { NATIVE } from '../wrapper'; import type { OnConfirmRoute, TransactionCreator } from './routingInstrumentation'; import { InternalRoutingInstrumentation } from './routingInstrumentation'; import type { BeforeNavigate, ReactNavigationTransactionContext, RouteChangeContextData } from './types'; @@ -26,13 +30,22 @@ interface ReactNavigationOptions { * before the transaction is discarded. * Time is in ms. * - * Default: 1000 + * @default 1000 */ routeChangeTimeoutMs: number; + + /** + * Time to initial display measures the time it takes from + * navigation dispatch to the render of the first frame of the new screen. + * + * @default false + */ + enableTimeToInitialDisplay: boolean; } const defaultOptions: ReactNavigationOptions = { routeChangeTimeoutMs: 1000, + enableTimeToInitialDisplay: false, }; /** @@ -49,11 +62,14 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati public readonly name: string = ReactNavigationInstrumentation.instrumentationName; private _navigationContainer: NavigationContainer | null = null; + private _newScreenFrameEventEmitter: SentryEventEmitter | null = null; private readonly _maxRecentRouteLen: number = 200; private _latestRoute?: NavigationRoute; private _latestTransaction?: TransactionType; + private _navigationProcessingSpan?: Span; + private _initialStateHandled: boolean = false; private _stateChangeTimeout?: number | undefined; private _recentRouteKeys: string[] = []; @@ -67,6 +83,14 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati ...defaultOptions, ...options, }; + + if (this._options.enableTimeToInitialDisplay) { + this._newScreenFrameEventEmitter = createSentryEventEmitter(); + this._newScreenFrameEventEmitter.initAsync(NewFrameEventName); + NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => { + logger.error(`[ReactNavigationInstrumentation] Failed to initialize native new frame tracking: ${reason}`); + }); + } } /** @@ -162,6 +186,14 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati getBlankTransactionContext(ReactNavigationInstrumentation.instrumentationName), ); + if (this._options.enableTimeToInitialDisplay) { + this._navigationProcessingSpan = startInactiveSpan({ + op: 'navigation.processing', + name: 'Navigation processing', + startTimestamp: this._latestTransaction?.startTimestamp, + }); + } + this._stateChangeTimeout = setTimeout( this._discardLatestTransaction.bind(this), this._options.routeChangeTimeoutMs, @@ -172,6 +204,8 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati * To be called AFTER the state has been changed to populate the transaction with the current route. */ private _onStateChange(): void { + const stateChangedTimestamp = timestampInSeconds(); + // Use the getCurrentRoute method to be accurate. const previousRoute = this._latestRoute; @@ -188,8 +222,51 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati if (route) { if (this._latestTransaction) { if (!previousRoute || previousRoute.key !== route.key) { - const originalContext = this._latestTransaction.toContext() as typeof BLANK_TRANSACTION_CONTEXT; const routeHasBeenSeen = this._recentRouteKeys.includes(route.key); + const latestTtidSpan = + !routeHasBeenSeen && + this._options.enableTimeToInitialDisplay && + startInactiveSpan({ + op: 'ui.load.initial_display', + name: `${route.name} initial display`, + startTimestamp: this._latestTransaction?.startTimestamp, + }); + + !routeHasBeenSeen && + this._newScreenFrameEventEmitter?.once( + NewFrameEventName, + ({ newFrameTimestampInSeconds }: NewFrameEvent) => { + if (!latestTtidSpan) { + return; + } + + if (spanToJSON(latestTtidSpan).parent_span_id !== getActiveSpan()?.spanContext().spanId) { + logger.warn( + '[ReactNavigationInstrumentation] Currently Active Span changed before the new frame was rendered, _latestTtidSpan is not a child of the currently active span.', + ); + return; + } + + latestTtidSpan.setStatus('ok'); + latestTtidSpan.end(newFrameTimestampInSeconds); + const ttidSpan = spanToJSON(latestTtidSpan); + + const ttidSpanEnd = ttidSpan.timestamp; + const ttidSpanStart = ttidSpan.start_timestamp; + if (!ttidSpanEnd || !ttidSpanStart) { + return; + } + + setMeasurement('time_to_initial_display', (ttidSpanEnd - ttidSpanStart) * 1000, 'millisecond'); + }, + ); + + this._navigationProcessingSpan?.updateName(`Processing navigation to ${route.name}`); + this._navigationProcessingSpan?.setStatus('ok'); + this._navigationProcessingSpan?.end(stateChangedTimestamp); + this._navigationProcessingSpan = undefined; + + const originalContext = this._latestTransaction.toContext() as typeof BLANK_TRANSACTION_CONTEXT; const data: RouteChangeContextData = { ...originalContext.data, @@ -286,6 +363,9 @@ export class ReactNavigationInstrumentation extends InternalRoutingInstrumentati this._latestTransaction.finish(); this._latestTransaction = undefined; } + if (this._navigationProcessingSpan) { + this._navigationProcessingSpan = undefined; + } } /** diff --git a/src/js/utils/sentryeventemitter.ts b/src/js/utils/sentryeventemitter.ts new file mode 100644 index 0000000000..63146f30c5 --- /dev/null +++ b/src/js/utils/sentryeventemitter.ts @@ -0,0 +1,111 @@ +import { logger } from '@sentry/utils'; +import type { EmitterSubscription, NativeModule } from 'react-native'; +import { NativeEventEmitter } from 'react-native'; + +import { getRNSentryModule } from '../wrapper'; + +export const NewFrameEventName = 'rn_sentry_new_frame'; +export type NewFrameEventName = typeof NewFrameEventName; +export type NewFrameEvent = { newFrameTimestampInSeconds: number }; + +export interface SentryEventEmitter { + /** + * Initializes the native event emitter + * This method is synchronous in JS but the native event emitter starts asynchronously + * https://github.com/facebook/react-native/blob/d09c02f9e2d468e4d0bde51890e312ae7003a3e6/packages/react-native/React/Modules/RCTEventEmitter.m#L95 + */ + initAsync: (eventType: NewFrameEventName) => void; + closeAllAsync: () => void; + addListener: (eventType: NewFrameEventName, listener: (event: NewFrameEvent) => void) => void; + removeListener: (eventType: NewFrameEventName, listener: (event: NewFrameEvent) => void) => void; + once: (eventType: NewFrameEventName, listener: (event: NewFrameEvent) => void) => void; +} + +/** + * Creates emitter that allows to listen to native RNSentry events + */ +export function createSentryEventEmitter( + sentryNativeModule: NativeModule | undefined = getRNSentryModule(), + createNativeEventEmitter: (nativeModule: NativeModule | undefined) => NativeEventEmitter = nativeModule => + new NativeEventEmitter(nativeModule), +): SentryEventEmitter { + if (!sentryNativeModule) { + return createNoopSentryEventEmitter(); + } + + const openNativeListeners = new Map(); + const listenersMap = new Map void, true>>(); + + const nativeEventEmitter = createNativeEventEmitter(getRNSentryModule()); + + const addListener = function (eventType: NewFrameEventName, listener: (event: NewFrameEvent) => void): void { + const map = listenersMap.get(eventType); + if (!map) { + logger.warn(`EventEmitter was not initialized for event type: ${eventType}`); + return; + } + listenersMap.get(eventType)?.set(listener, true); + }; + + const removeListener = function (eventType: NewFrameEventName, listener: (event: NewFrameEvent) => void): void { + listenersMap.get(eventType)?.delete(listener); + }; + + return { + initAsync(eventType: NewFrameEventName) { + if (openNativeListeners.has(eventType)) { + return; + } + + const nativeListener = nativeEventEmitter.addListener(eventType, (event: NewFrameEvent) => { + const listeners = listenersMap.get(eventType); + if (!listeners) { + return; + } + + listeners.forEach((_, listener) => { + listener(event); + }); + }); + openNativeListeners.set(eventType, nativeListener); + + listenersMap.set(eventType, new Map()); + }, + closeAllAsync() { + openNativeListeners.forEach(subscription => { + subscription.remove(); + }); + openNativeListeners.clear(); + listenersMap.clear(); + }, + addListener, + removeListener, + once(eventType: NewFrameEventName, listener: (event: NewFrameEvent) => void) { + const tmpListener = (event: NewFrameEvent): void => { + listener(event); + removeListener(eventType, tmpListener); + }; + addListener(eventType, tmpListener); + }, + }; +} + +function createNoopSentryEventEmitter(): SentryEventEmitter { + return { + initAsync: () => { + logger.warn('Noop SentryEventEmitter: initAsync'); + }, + closeAllAsync: () => { + logger.warn('Noop SentryEventEmitter: closeAllAsync'); + }, + addListener: () => { + logger.warn('Noop SentryEventEmitter: addListener'); + }, + removeListener: () => { + logger.warn('Noop SentryEventEmitter: removeListener'); + }, + once: () => { + logger.warn('Noop SentryEventEmitter: once'); + }, + }; +} diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index 40d55ebc09..1d27a9d056 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -30,9 +30,16 @@ import { isTurboModuleEnabled } from './utils/environment'; import { ReactNativeLibraries } from './utils/rnlibraries'; import { base64StringFromByteArray, utf8ToBytes } from './vendor'; -const RNSentry: Spec | undefined = isTurboModuleEnabled() - ? ReactNativeLibraries.TurboModuleRegistry && ReactNativeLibraries.TurboModuleRegistry.get('RNSentry') - : NativeModules.RNSentry; +/** + * Returns the RNSentry module. Dynamically resolves if NativeModule or TurboModule is used. + */ +export function getRNSentryModule(): Spec | undefined { + return isTurboModuleEnabled() + ? ReactNativeLibraries.TurboModuleRegistry && ReactNativeLibraries.TurboModuleRegistry.get('RNSentry') + : NativeModules.RNSentry; +} + +const RNSentry: Spec | undefined = getRNSentryModule(); export interface Screenshot { data: Uint8Array; @@ -96,6 +103,7 @@ interface SentryNativeWrapper { * Fetches native stack frames and debug images for the instructions addresses. */ fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | null; + initNativeReactNavigationNewFrameTracking(): Promise; } const EOL = utf8ToBytes('\n'); @@ -589,6 +597,17 @@ export const NATIVE: SentryNativeWrapper = { return RNSentry.fetchNativeStackFramesBy(instructionsAddr) || null; }, + async initNativeReactNavigationNewFrameTracking(): Promise { + if (!this.enableNative) { + return; + } + if (!this._isModuleLoaded(RNSentry)) { + return; + } + + return RNSentry.initNativeReactNavigationNewFrameTracking(); + }, + /** * Gets the event from envelopeItem and applies the level filter to the selected event. * @param data An envelope item containing the event. diff --git a/test/mockWrapper.ts b/test/mockWrapper.ts index d904b0c872..360211a40a 100644 --- a/test/mockWrapper.ts +++ b/test/mockWrapper.ts @@ -1,4 +1,4 @@ -import type { NATIVE as ORIGINAL_NATIVE } from '../src/js/wrapper'; +import { type NATIVE as ORIGINAL_NATIVE } from '../src/js/wrapper'; import type { MockInterface } from './testutils'; type NativeType = typeof ORIGINAL_NATIVE; @@ -51,6 +51,8 @@ const NATIVE: MockInterface = { fetchNativePackageName: jest.fn(), fetchNativeStackFramesBy: jest.fn(), + + initNativeReactNavigationNewFrameTracking: jest.fn(), }; NATIVE.isNativeAvailable.mockReturnValue(true); @@ -69,7 +71,10 @@ NATIVE.fetchModules.mockResolvedValue(null); NATIVE.fetchViewHierarchy.mockResolvedValue(null); NATIVE.startProfiling.mockReturnValue(false); NATIVE.stopProfiling.mockReturnValue(null); -NATIVE.fetchNativePackageName.mockResolvedValue('mock-native-package-name'); -NATIVE.fetchNativeStackFramesBy.mockResolvedValue(null); +NATIVE.fetchNativePackageName.mockReturnValue('mock-native-package-name'); +NATIVE.fetchNativeStackFramesBy.mockReturnValue(null); +NATIVE.initNativeReactNavigationNewFrameTracking.mockReturnValue(Promise.resolve()); + +export const getRNSentryModule = jest.fn(); export { NATIVE }; diff --git a/test/sdk.test.ts b/test/sdk.test.ts index dd906d54d8..201a192762 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -3,8 +3,6 @@ */ import { logger } from '@sentry/utils'; -import { NATIVE } from '../src/js/wrapper'; - interface MockedClient { flush: jest.Mock; } @@ -62,7 +60,8 @@ jest.mock('../src/js/client', () => { }; }); -jest.mock('../src/js/wrapper'); +import * as mockedWrapper from './mockWrapper'; +jest.mock('../src/js/wrapper', () => mockedWrapper); jest.mock('../src/js/utils/environment'); jest.spyOn(logger, 'error'); @@ -76,6 +75,7 @@ import { configureScope, flush, init, withScope } from '../src/js/sdk'; import { ReactNativeTracing, ReactNavigationInstrumentation } from '../src/js/tracing'; import { makeNativeTransport } from '../src/js/transports/native'; import { getDefaultEnvironment, isExpoGo, notWeb } from '../src/js/utils/environment'; +import { NATIVE } from './mockWrapper'; import { firstArg, secondArg } from './testutils'; const mockedInitAndBind = initAndBind as jest.MockedFunction; diff --git a/test/tracing/reactnavigation.ttid.test.ts b/test/tracing/reactnavigation.ttid.test.ts new file mode 100644 index 0000000000..39cddcdb3a --- /dev/null +++ b/test/tracing/reactnavigation.ttid.test.ts @@ -0,0 +1,497 @@ +import * as mockWrapper from '../mockWrapper'; +import * as mockedSentryEventEmitter from '../utils/mockedSentryeventemitter'; +jest.mock('../../src/js/wrapper', () => mockWrapper); +jest.mock('../../src/js/utils/environment'); +jest.mock('../../src/js/utils/sentryeventemitter', () => mockedSentryEventEmitter); + +import type { SpanJSON, TransactionEvent, Transport } from '@sentry/types'; +import { timestampInSeconds } from '@sentry/utils'; + +import * as Sentry from '../../src/js'; +import { ReactNavigationInstrumentation } from '../../src/js'; +import type { NavigationRoute } from '../../src/js/tracing/reactnavigation'; +import { isHermesEnabled, notWeb } from '../../src/js/utils/environment'; +import { createSentryEventEmitter } from '../../src/js/utils/sentryeventemitter'; +import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; +import { MOCK_DSN } from '../mockDsn'; +import type { MockedSentryEventEmitter } from '../utils/mockedSentryeventemitter'; + +describe('React Navigation - TTID', () => { + let mockedEventEmitter: MockedSentryEventEmitter; + let transportSendMock: jest.Mock, Parameters>; + let mockedNavigation: ReturnType; + const mockedAppStartTimeSeconds: number = timestampInSeconds(); + + describe('ttid enabled', () => { + beforeEach(() => { + jest.useFakeTimers(); + (notWeb as jest.Mock).mockReturnValue(true); + (isHermesEnabled as jest.Mock).mockReturnValue(true); + + mockWrapper.NATIVE.fetchNativeAppStart.mockResolvedValue({ + appStartTime: mockedAppStartTimeSeconds * 1000, + didFetchAppStart: false, + isColdStart: true, + }); + + mockedEventEmitter = mockedSentryEventEmitter.createMockedSentryEventEmitter(); + (createSentryEventEmitter as jest.Mock).mockReturnValue(mockedEventEmitter); + + const sut = createTestedInstrumentation({ enableTimeToInitialDisplay: true }); + transportSendMock = initSentry(sut).transportSendMock; + + mockedNavigation = createMockNavigationAndAttachTo(sut); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('should add ttid span', () => { + jest.runOnlyPendingTimers(); // Flush app start transaction + + mockedNavigation.navigateToNewScreen(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush ttid transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + spans: expect.arrayContaining([ + expect.objectContaining>({ + data: { + 'sentry.op': 'ui.load.initial_display', + 'sentry.origin': 'manual', + }, + description: 'New Screen initial display', + op: 'ui.load.initial_display', + origin: 'manual', + status: 'ok', + start_timestamp: transaction.start_timestamp, + timestamp: expect.any(Number), + }), + ]), + }), + ); + }); + + test('should add ttid measurement', () => { + jest.runOnlyPendingTimers(); // Flush app start transaction + + mockedNavigation.navigateToNewScreen(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush ttid transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + measurements: expect.objectContaining['measurements']>({ + time_to_initial_display: { + value: expect.any(Number), + unit: 'millisecond', + }, + }), + }), + ); + }); + + test('should add processing navigation span', () => { + jest.runOnlyPendingTimers(); // Flush app start transaction + + mockedNavigation.navigateToNewScreen(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush ttid transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + spans: expect.arrayContaining([ + expect.objectContaining>({ + data: { + 'sentry.op': 'navigation.processing', + 'sentry.origin': 'manual', + }, + description: 'Processing navigation to New Screen', + op: 'navigation.processing', + origin: 'manual', + status: 'ok', + start_timestamp: transaction.start_timestamp, + timestamp: expect.any(Number), + }), + ]), + }), + ); + }); + + test('should add processing navigation span for application start up', () => { + mockedNavigation.finishAppStartNavigation(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush ttid transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + spans: expect.arrayContaining([ + expect.objectContaining>({ + data: { + 'sentry.op': 'navigation.processing', + 'sentry.origin': 'manual', + }, + description: 'Processing navigation to Initial Screen', + op: 'navigation.processing', + origin: 'manual', + status: 'ok', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ]), + }), + ); + }); + + test('should add ttid span for application start up', () => { + mockedNavigation.finishAppStartNavigation(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush ttid transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + spans: expect.arrayContaining([ + expect.objectContaining>({ + description: 'Cold App Start', + }), + expect.objectContaining>({ + data: { + 'sentry.op': 'ui.load.initial_display', + 'sentry.origin': 'manual', + }, + description: 'Initial Screen initial display', + op: 'ui.load.initial_display', + origin: 'manual', + status: 'ok', + start_timestamp: mockedAppStartTimeSeconds, + timestamp: expect.any(Number), + }), + ]), + }), + ); + }); + + test('should add ttid measurement for application start up', () => { + mockedNavigation.finishAppStartNavigation(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush ttid transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + spans: expect.arrayContaining([ + expect.objectContaining>({ + description: 'Cold App Start', + }), + ]), + measurements: expect.objectContaining['measurements']>({ + time_to_initial_display: { + value: expect.any(Number), + unit: 'millisecond', + }, + }), + }), + ); + }); + + test('ttid span duration and measurement should equal for application start up', () => { + mockedNavigation.finishAppStartNavigation(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush ttid transaction + + const transaction = getLastTransaction(transportSendMock); + expect(getTTIDSpanDurationMs(transaction)).toBeDefined(); + expect(transaction.measurements?.time_to_initial_display?.value).toBeDefined(); + expect(getTTIDSpanDurationMs(transaction)).toEqual(transaction.measurements?.time_to_initial_display?.value); + }); + + test('idle transaction should cancel the ttid span if new frame not received', () => { + mockedNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush ttid transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + spans: expect.arrayContaining([ + expect.objectContaining>({ + data: { + 'sentry.op': 'ui.load.initial_display', + 'sentry.origin': 'manual', + }, + description: 'New Screen initial display', + op: 'ui.load.initial_display', + origin: 'manual', + status: 'cancelled', + start_timestamp: transaction.start_timestamp, + timestamp: expect.any(Number), + }), + ]), + }), + ); + }); + + test('should not sample empty back navigation transactions with navigation processing', () => { + jest.runOnlyPendingTimers(); // Flush app start transaction + + mockedNavigation.navigateToNewScreen(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush transaction + + mockedNavigation.navigateToInitialScreen(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'New Screen', + }), + ); + }); + + test('should not add ttid span and measurement back navigation transactions', () => { + jest.runOnlyPendingTimers(); // Flush app start transaction + + mockedNavigation.navigateToNewScreen(); + mockedEventEmitter.emitNewFrameEvent(); + jest.runOnlyPendingTimers(); // Flush transaction + + mockedNavigation.navigateToInitialScreen(); + mockedEventEmitter.emitNewFrameEvent(); + const artificialSpan = Sentry.startInactiveSpan({ + name: 'Artificial span to ensure back navigation transaction is not empty', + }); + artificialSpan?.end(); + jest.runOnlyPendingTimers(); // Flush transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'Initial Screen', + spans: expect.not.arrayContaining([ + expect.objectContaining>({ + op: 'ui.load.initial_display', + }), + ]), + }), + ); + expect(transaction.measurements).toBeOneOf([ + undefined, + expect.not.objectContaining['measurements']>({ + time_to_initial_display: expect.any(Object), + }), + ]); + }); + }); + + describe('ttid disabled', () => { + beforeEach(() => { + jest.useFakeTimers(); + (notWeb as jest.Mock).mockReturnValue(true); + (isHermesEnabled as jest.Mock).mockReturnValue(true); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it.each([undefined, {}, { enableTimeToInitialDisplay: undefined }, { enableTimeToInitialDisplay: false }])( + 'should not add ttid span with options %s', + options => { + const sut = createTestedInstrumentation(options); + transportSendMock = initSentry(sut).transportSendMock; + + mockedNavigation = createMockNavigationAndAttachTo(sut); + + jest.runOnlyPendingTimers(); // Flush app start transaction + mockedNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush navigation transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + spans: expect.not.arrayContaining([ + expect.objectContaining>({ + op: 'ui.load.initial_display', + }), + ]), + }), + ); + }, + ); + + test('should not add ttid measurement', () => { + jest.runOnlyPendingTimers(); // Flush app start transaction + mockedNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush navigation transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction.measurements).toBeOneOf([ + undefined, + expect.not.objectContaining['measurements']>({ + time_to_initial_display: expect.any(Object), + }), + ]); + }); + + test('should not add processing navigation span', () => { + jest.runOnlyPendingTimers(); // Flush app start transaction + mockedNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush navigation transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + spans: expect.not.arrayContaining([ + expect.objectContaining>({ + op: 'navigation.processing', + }), + ]), + }), + ); + }); + }); + + function getTTIDSpanDurationMs(transaction: TransactionEvent): number | undefined { + const ttidSpan = transaction.spans?.find(span => span.op === 'ui.load.initial_display'); + if (!ttidSpan) { + return undefined; + } + + const spanJSON = ttidSpan as unknown as SpanJSON; // the JS SDK typings are not correct + if (!spanJSON.timestamp || !spanJSON.start_timestamp) { + return undefined; + } + + return (spanJSON.timestamp - spanJSON.start_timestamp) * 1000; + } + + function createTestedInstrumentation(options?: { enableTimeToInitialDisplay?: boolean }) { + const sut = new ReactNavigationInstrumentation(options); + return sut; + } + + function createMockNavigationAndAttachTo(sut: ReactNavigationInstrumentation) { + const mockedNavigationContained = mockNavigationContainer(); + const mockedNavigation = { + navigateToNewScreen: () => { + mockedNavigationContained.listeners['__unsafe_action__']({ + // this object is not used by the instrumentation + }); + mockedNavigationContained.currentRoute = { + key: 'new_screen', + name: 'New Screen', + }; + mockedNavigationContained.listeners['state']({ + // this object is not used by the instrumentation + }); + }, + navigateToInitialScreen: () => { + mockedNavigationContained.listeners['__unsafe_action__']({ + // this object is not used by the instrumentation + }); + mockedNavigationContained.currentRoute = { + key: 'initial_screen', + name: 'Initial Screen', + }; + mockedNavigationContained.listeners['state']({ + // this object is not used by the instrumentation + }); + }, + finishAppStartNavigation: () => { + mockedNavigationContained.currentRoute = { + key: 'initial_screen', + name: 'Initial Screen', + }; + mockedNavigationContained.listeners['state']({ + // this object is not used by the instrumentation + }); + }, + }; + sut.registerNavigationContainer(mockRef(mockedNavigationContained)); + + return mockedNavigation; + } + + function mockNavigationContainer(): MockNavigationContainer { + return new MockNavigationContainer(); + } + + function mockRef(wat: T): { current: T } { + return { + current: wat, + }; + } + + function getLastTransaction(mockedTransportSend: jest.Mock): TransactionEvent { + // Until https://github.com/getsentry/sentry-javascript/blob/a7097d9ba2a74b2cb323da0ef22988a383782ffb/packages/types/src/event.ts#L93 + return JSON.parse(JSON.stringify(mockedTransportSend.mock.lastCall[0][1][0][1])); + } +}); + +class MockNavigationContainer { + currentRoute: NavigationRoute = { + key: 'initial_screen', + name: 'Initial Screen', + }; + listeners: Record void> = {}; + addListener: any = jest.fn((eventType: string, listener: (e: any) => void): void => { + this.listeners[eventType] = listener; + }); + getCurrentRoute(): NavigationRoute | undefined { + return this.currentRoute; + } +} + +function initSentry(sut: ReactNavigationInstrumentation): { + transportSendMock: jest.Mock, Parameters>; +} { + RN_GLOBAL_OBJ.__sentry_rn_v5_registered = false; + const transportSendMock = jest.fn, Parameters>(); + const options: Sentry.ReactNativeOptions = { + dsn: MOCK_DSN, + enableTracing: true, + integrations: [ + new Sentry.ReactNativeTracing({ + routingInstrumentation: sut, + enableStallTracking: false, + ignoreEmptyBackNavigationTransactions: true, // default true + }), + ], + transport: () => ({ + send: transportSendMock.mockResolvedValue(undefined), + flush: jest.fn().mockResolvedValue(true), + }), + }; + Sentry.init(options); + + // In production integrations are setup only once, but in the tests we want them to setup on every init + const integrations = Sentry.getCurrentHub().getClient()?.getOptions().integrations; + if (integrations) { + for (const integration of integrations) { + integration.setupOnce(Sentry.addGlobalEventProcessor, Sentry.getCurrentHub); + } + } + + return { + transportSendMock, + }; +} diff --git a/test/utils/mockedSentryeventemitter.ts b/test/utils/mockedSentryeventemitter.ts new file mode 100644 index 0000000000..80e909f17a --- /dev/null +++ b/test/utils/mockedSentryeventemitter.ts @@ -0,0 +1,37 @@ +import { timestampInSeconds } from '@sentry/utils'; +import EventEmitter from 'events'; + +import type { NewFrameEvent, SentryEventEmitter } from '../../src/js/utils/sentryeventemitter'; +import type { MockInterface } from '../testutils'; + +export const NewFrameEventName = 'rn_sentry_new_frame'; +export type NewFrameEventName = typeof NewFrameEventName; + +export interface MockedSentryEventEmitter extends MockInterface { + emitNewFrameEvent: () => void; +} + +export function createMockedSentryEventEmitter(): MockedSentryEventEmitter { + const emitter = new EventEmitter(); + + return { + emitNewFrameEvent: jest.fn(() => { + emitter.emit('rn_sentry_new_frame', { newFrameTimestampInSeconds: timestampInSeconds() }); + }), + once: jest.fn((event: NewFrameEventName, listener: (event: NewFrameEvent) => void) => { + emitter.once(event, listener); + }), + removeListener: jest.fn((event: NewFrameEventName, listener: (event: NewFrameEvent) => void) => { + emitter.removeListener(event, listener); + }), + addListener: jest.fn((event: NewFrameEventName, listener: (event: NewFrameEvent) => void) => { + emitter.addListener(event, listener); + }), + initAsync: jest.fn(), + closeAllAsync: jest.fn(() => { + emitter.removeAllListeners(); + }), + }; +} + +export const createSentryEventEmitter = jest.fn(() => createMockedSentryEventEmitter()); diff --git a/test/utils/sentryeventemitter.test.ts b/test/utils/sentryeventemitter.test.ts new file mode 100644 index 0000000000..8f925b9ca6 --- /dev/null +++ b/test/utils/sentryeventemitter.test.ts @@ -0,0 +1,91 @@ +import type { NewFrameEventName } from '../../src/js/utils/sentryeventemitter'; +import { createSentryEventEmitter } from '../../src/js/utils/sentryeventemitter'; + +describe('Sentry Event Emitter', () => { + let mockedRemoveListener: jest.Mock; + let mockedAddListener: jest.Mock; + let mockedCreateNativeEventEmitter: jest.Mock; + + beforeEach(() => { + mockedRemoveListener = jest.fn(); + mockedAddListener = jest.fn().mockReturnValue({ remove: mockedRemoveListener }); + mockedCreateNativeEventEmitter = jest.fn().mockReturnValue({ addListener: mockedAddListener }); + }); + + test('should create noop emitter if sentry native module is not available', () => { + const sut = createSentryEventEmitter(undefined, mockedCreateNativeEventEmitter); + + expect(sut).toBeDefined(); + expect(mockedCreateNativeEventEmitter).not.toBeCalled(); + }); + + test('should add listener to the native event emitter when initialized', () => { + const sut = createSentryEventEmitter({} as any, mockedCreateNativeEventEmitter); + + sut.initAsync('rn_sentry_new_frame'); + + expect(mockedCreateNativeEventEmitter).toBeCalledTimes(1); + expect(mockedAddListener).toBeCalledWith('rn_sentry_new_frame', expect.any(Function)); + }); + + test('should not add listener to the native event emitter when initialized if already initialized', () => { + const sut = createSentryEventEmitter({} as any, mockedCreateNativeEventEmitter); + + sut.initAsync('rn_sentry_new_frame'); + sut.initAsync('rn_sentry_new_frame'); + + expect(mockedCreateNativeEventEmitter).toBeCalledTimes(1); + expect(mockedAddListener).toBeCalledTimes(1); + }); + + test('should remove all native listeners when closed', () => { + const sut = createSentryEventEmitter({} as any, mockedCreateNativeEventEmitter); + + sut.initAsync('rn_sentry_new_frame'); + sut.initAsync('test_event' as NewFrameEventName); + sut.closeAllAsync(); + + expect(mockedRemoveListener).toBeCalledTimes(2); + }); + + test('should call added listeners when native event is emitted', () => { + const sut = createSentryEventEmitter({} as any, mockedCreateNativeEventEmitter); + + const listener = jest.fn(); + sut.initAsync('rn_sentry_new_frame'); + sut.addListener('rn_sentry_new_frame', listener); + + const nativeListener = mockedAddListener.mock.calls[0][1]; + nativeListener({ type: 'rn_sentry_new_frame' }); + + expect(listener).toBeCalledTimes(1); + }); + + test('should not call removed listeners when native event is emitted', () => { + const sut = createSentryEventEmitter({} as any, mockedCreateNativeEventEmitter); + + const listener = jest.fn(); + sut.initAsync('rn_sentry_new_frame'); + sut.addListener('rn_sentry_new_frame', listener); + sut.removeListener('rn_sentry_new_frame', listener); + + const nativeListener = mockedAddListener.mock.calls[0][1]; + nativeListener({ type: 'rn_sentry_new_frame' }); + + expect(listener).not.toBeCalled(); + }); + + test('should call once listeners only once when native event is emitted', () => { + const sut = createSentryEventEmitter({} as any, mockedCreateNativeEventEmitter); + + const listener = jest.fn(); + sut.initAsync('rn_sentry_new_frame'); + sut.once('rn_sentry_new_frame', listener); + + const nativeListener = mockedAddListener.mock.calls[0][1]; + nativeListener({ type: 'rn_sentry_new_frame' }); + nativeListener({ type: 'rn_sentry_new_frame' }); + + expect(listener).toBeCalledTimes(1); + }); +});