diff --git a/CHANGELOG.md b/CHANGELOG.md index 777b43a2a1..7651723e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Change `gradle.projectsEvaluated` to `project.afterEvaluate` in the Sentry Gradle Plugin to fix tasks not being created when using `--configure-on-demand` ([#4687](https://github.com/getsentry/sentry-react-native/pull/4687)) - Remove `SENTRY_FORCE_FOREGROUND` from Xcode Scripts as the underlying `--force-foreground` Sentry CLI is no-op since v2.37.0 ([#4689](https://github.com/getsentry/sentry-react-native/pull/4689)) +- TTID and TTFD use native getters instead of events to pass timestamps to the JS layer ([#4669](https://github.com/getsentry/sentry-react-native/pull/4669)) ## 6.10.0 diff --git a/packages/core/RNSentryAndroidTester/app/build.gradle b/packages/core/RNSentryAndroidTester/app/build.gradle index c47402b891..26334b0f85 100644 --- a/packages/core/RNSentryAndroidTester/app/build.gradle +++ b/packages/core/RNSentryAndroidTester/app/build.gradle @@ -43,6 +43,7 @@ dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'com.google.android.material:material:1.5.0' + implementation 'androidx.test:core-ktx:1.6.1' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.10.0' testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1' diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt new file mode 100644 index 0000000000..183895561d --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt @@ -0,0 +1,166 @@ +package io.sentry.react + +import android.app.Activity +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.facebook.react.bridge.ReactApplicationContext +import io.sentry.android.core.BuildInfoProvider +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RNSentryOnDrawReporterTest { + companion object { + private const val TTID_PREFIX = RNSentryOnDrawReporterManager.TTID_PREFIX + private const val TTFD_PREFIX = RNSentryOnDrawReporterManager.TTFD_PREFIX + private const val SPAN_ID = "test-span-id" + private const val NEW_SPAN_ID = "new-test-span-id" + } + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `when parentSpanId and timeToFullDisplay are set the next render timestamp is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when parentSpanId and timeToInitialDisplay are set the next render timestamp is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + @Test + fun `when parentSpanId and timeToFullDisplay are set the next render timestamp is saved - reversed order`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setParentSpanId(SPAN_ID) + reporter.setFullDisplay(true) + + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when parentSpanId and timeToInitialDisplay are set the next render timestamp is saved - reversed order`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setParentSpanId(SPAN_ID) + reporter.setInitialDisplay(true) + + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + @Test + fun `when display flag and parentSpanId changes the next full display render is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID) + + reporter.setFullDisplay(false) + reporter.setFullDisplay(true) + reporter.setParentSpanId(NEW_SPAN_ID) + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + NEW_SPAN_ID)) + } + + @Test + fun `when display flag and parentSpanId changes the next initial display render is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID) + + reporter.setInitialDisplay(false) + reporter.setInitialDisplay(true) + reporter.setParentSpanId(NEW_SPAN_ID) + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + NEW_SPAN_ID)) + } + + @Test + fun `when parentSpanId doesn't change the next full display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID) + + reporter.setFullDisplay(false) + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when parentSpanId doesn't change the next initial display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID) + + reporter.setInitialDisplay(false) + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + @Test + fun `when display flag doesn't change the next full display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID) + + reporter.setFullDisplay(true) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when display flag doesn't change the next initial display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID) + + reporter.setInitialDisplay(true) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + class TestRNSentryOnDrawReporterView( + context: Context, + reactContext: ReactApplicationContext, + buildInfo: BuildInfoProvider, + ) : RNSentryOnDrawReporterManager.RNSentryOnDrawReporterView(context, reactContext, buildInfo) { + override fun registerForNextDraw( + activity: Activity, + callback: Runnable, + buildInfo: BuildInfoProvider, + ) { + callback.run() + } + } + + private fun createTestRNSentryOnDrawReporterView(): TestRNSentryOnDrawReporterView = + TestRNSentryOnDrawReporterView(ApplicationProvider.getApplicationContext(), mockReactContext(), mockBuildInfo()) + + private fun mockReactContext(): ReactApplicationContext { + val reactContext = mock() + whenever(reactContext.getCurrentActivity()).thenReturn(mock()) + return reactContext + } + + private fun mockBuildInfo(): BuildInfoProvider = mock() +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryTimeToDisplayTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryTimeToDisplayTest.kt new file mode 100644 index 0000000000..5ec953b287 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryTimeToDisplayTest.kt @@ -0,0 +1,48 @@ +package io.sentry.react + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class RNSentryTimeToDisplayTest { + companion object { + val TEST_ID = "test-id" + val TEST_VAL = 123.4 + } + + @Before + fun setUp() { + } + + @Test + fun `puts and pops record`() { + RNSentryTimeToDisplay.putTimeToDisplayFor(TEST_ID, TEST_VAL) + + val firstPop = RNSentryTimeToDisplay.popTimeToDisplayFor(TEST_ID) + val secondPop = RNSentryTimeToDisplay.popTimeToDisplayFor(TEST_ID) + + assertEquals(firstPop, TEST_VAL, 0.0) + assertNull(secondPop) + } + + @Test + fun `removes oldes entry when full`() { + val maxSize = RNSentryTimeToDisplay.ENTRIES_MAX_SIZE + 1 + for (i in 1..maxSize) { + RNSentryTimeToDisplay.putTimeToDisplayFor("$TEST_ID-$i", i.toDouble()) + } + + val oldestEntry = RNSentryTimeToDisplay.popTimeToDisplayFor("$TEST_ID-1") + val secondOldestEntry = RNSentryTimeToDisplay.popTimeToDisplayFor("$TEST_ID-2") + val newestEntry = RNSentryTimeToDisplay.popTimeToDisplayFor("$TEST_ID-$maxSize") + + assertNull(oldestEntry) + assertNotNull(secondOldestEntry) + assertNotNull(newestEntry) + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 48489ab216..9d1b45fe80 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -11,9 +11,11 @@ 3339C4812D6625570088EB3A /* RNSentryUserTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3339C4802D6625570088EB3A /* RNSentryUserTests.mm */; }; 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */; }; 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */; }; - 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */; }; 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; }; + 33DEDFEA2D8DBE67006066E4 /* RNSentryOnDrawReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */; }; + 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */; }; + 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */; }; 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.mm */; }; AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */; }; B5859A50A3E865EF5E61465A /* libPods-RNSentryCocoaTesterTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */; }; @@ -37,12 +39,16 @@ 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryReplayPostInitTests.swift; sourceTree = ""; }; 338739072A7D7D2800950DDD /* RNSentryReplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplay.h; path = ../ios/RNSentryReplay.h; sourceTree = ""; }; 33958C672BFCEF5A00AD1FB6 /* RNSentryOnDrawReporter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryOnDrawReporter.h; path = ../ios/RNSentryOnDrawReporter.h; sourceTree = ""; }; - 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryOnDrawReporterTests.m; 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 = ""; }; + 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryOnDrawReporterTests.swift; sourceTree = ""; }; + 33DEDFEB2D8DC800006066E4 /* RNSentryOnDrawReporter+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryOnDrawReporter+Test.h"; sourceTree = ""; }; + 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = "RNSentryOnDrawReporter+Test.mm"; sourceTree = ""; }; + 33DEDFEE2D8DD431006066E4 /* RNSentryTimeToDisplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryTimeToDisplay.h; path = ../ios/RNSentryTimeToDisplay.h; sourceTree = SOURCE_ROOT; }; + 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryTimeToDisplayTests.swift; sourceTree = ""; }; 33F58ACF2977037D008F60EA /* RNSentryTests.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 = ""; }; @@ -91,6 +97,10 @@ 3360899029524164007C7730 /* RNSentryCocoaTesterTests */ = { isa = PBXGroup; children = ( + 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */, + 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */, + 33DEDFEB2D8DC800006066E4 /* RNSentryOnDrawReporter+Test.h */, + 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */, 3339C47F2D6625260088EB3A /* RNSentry+Test.h */, 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */, 332D33492CDCC8E100547D76 /* RNSentryTests.h */, @@ -101,7 +111,6 @@ 33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */, 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */, 33AFDFF22B8D15F600AAB120 /* RNSentryDependencyContainerTests.h */, - 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */, 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */, 332D33462CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift */, 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */, @@ -121,6 +130,7 @@ 33AFE0122B8F319000AAB120 /* RNSentry */ = { isa = PBXGroup; children = ( + 33DEDFEE2D8DD431006066E4 /* RNSentryTimeToDisplay.h */, 3380C6C02CDEC56B0018B9B6 /* Replay */, 332D33482CDBDC7300547D76 /* RNSentry.h */, 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */, @@ -244,11 +254,13 @@ files = ( AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */, 332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */, + 33DEDFEA2D8DBE67006066E4 /* RNSentryOnDrawReporterTests.swift in Sources */, 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */, 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */, + 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */, 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */, - 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */, 3339C4812D6625570088EB3A /* RNSentryUserTests.mm in Sources */, + 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */, 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */, 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */, ); diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h index bc2bdd0304..a00ee1747c 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h @@ -3,7 +3,9 @@ // #import "RNSentryBreadcrumb.h" +#import "RNSentryOnDrawReporter+Test.h" #import "RNSentryReplay.h" #import "RNSentryReplayBreadcrumbConverter.h" #import "RNSentryReplayMask.h" #import "RNSentryReplayUnmask.h" +#import "RNSentryTimeToDisplay.h" diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h new file mode 100644 index 0000000000..2ef701d215 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h @@ -0,0 +1,10 @@ +#import "RNSentryOnDrawReporter.h" +#import + +@interface +RNSentryOnDrawReporterView (Testing) + ++ (instancetype)createWithMockedListener; +- (RNSentryEmitNewFrameEvent)createEmitNewFrameEvent; + +@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm new file mode 100644 index 0000000000..3aca532855 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm @@ -0,0 +1,49 @@ +#import "RNSentryOnDrawReporter+Test.h" + +@interface MockedListener : NSObject +@property (strong, nonatomic) RNSentryEmitNewFrameEvent emitNewFrameEvent; +- (instancetype)initWithMockedListener:(RNSentryEmitNewFrameEvent)emitNewFrameEvent; +@end + +@implementation MockedListener + +- (instancetype)initWithMockedListener:(RNSentryEmitNewFrameEvent)emitNewFrameEvent +{ + self = [super init]; + if (self) { + _emitNewFrameEvent = [emitNewFrameEvent copy]; + } + return self; +} + +- (void)startListening +{ + self.emitNewFrameEvent(@([[NSDate date] timeIntervalSince1970])); +} + +- (void)framesTrackerHasNewFrame:(nonnull NSDate *)newFrameDate +{ + NSLog(@"Not implemented in the test mock"); +} + +@end + +@implementation +RNSentryOnDrawReporterView (Testing) + ++ (instancetype)createWithMockedListener +{ + return [[self alloc] initWithMockedListener]; +} + +- (instancetype)initWithMockedListener +{ + self = [super init]; + if (self) { + self.framesListener = + [[MockedListener alloc] initWithMockedListener:[self createEmitNewFrameEvent]]; + } + return self; +} + +@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m deleted file mode 100644 index 13de6a12c9..0000000000 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m +++ /dev/null @@ -1,16 +0,0 @@ -#import "RNSentryOnDrawReporter.h" -#import - -@interface RNSentryOnDrawReporterTests : XCTestCase - -@end - -@implementation RNSentryOnDrawReporterTests - -- (void)testRNSentryOnDrawReporterViewIsAvailableWhenUIKitIs -{ - RNSentryOnDrawReporterView *view = [[RNSentryOnDrawReporterView alloc] init]; - XCTAssertNotNil(view); -} - -@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift new file mode 100644 index 0000000000..ce3f37bb5c --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift @@ -0,0 +1,122 @@ +import Sentry +import XCTest + +final class RNSentryOnDrawReporterTests: XCTestCase { + private let ttidPrefix = "ttid-" + private let ttfdPrefix = "ttfd-" + private let spanId = "test-span-id" + private let newSpanId = "new-test-span-id" + + func testRNSentryOnDrawReporterViewIsAvailableWhenUIKitIs() { + let view = RNSentryOnDrawReporterView() + XCTAssertNotNil(view) + } + + func testWhenParentSpanIdAndTimeToFullDisplayAreSetTheNextRenderTimestampIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)) + } + + func testWhenParentSpanIdAndTimeToInitialDisplayAreSetTheNextRenderTimestampIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)) + } + + func testWhenDisplayFlagAndParentSpanIdChangesTheNextFullDisplayRenderIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.fullDisplay = false + reporter!.didSetProps(["fullDisplay"]) + reporter!.fullDisplay = true + reporter!.parentSpanId = newSpanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + newSpanId)) + } + + func testWhenDisplayFlagAndParentSpanIdChangesTheNextInitialDisplayRenderIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.initialDisplay = false + reporter!.didSetProps(["initalDisplay"]) + reporter!.initialDisplay = true + reporter!.parentSpanId = newSpanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + newSpanId)) + } + + func testWhenParentSpanIdDoesntChangeTheNextFullDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.fullDisplay = false + reporter!.didSetProps(["fullDisplay"]) + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)) + } + + func testWhenParentSpanIdDoesntChangeTheNextInitialDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId) + + reporter!.initialDisplay = false + reporter!.didSetProps(["initalDisplay"]) + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)) + } + + func testWhenDisplayFlagDoesntChangeTheNextFullDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.fullDisplay = true + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)) + } + + func testWhenDisplayFlagDoesntChangeTheNextInitialDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId) + + reporter!.initialDisplay = true + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)) + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTimeToDisplayTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTimeToDisplayTests.swift new file mode 100644 index 0000000000..cdeb8258a4 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTimeToDisplayTests.swift @@ -0,0 +1,60 @@ +import Sentry +import XCTest + +final class RNSentryTimeToDisplayTests: XCTestCase { + private let TEST_ID = "test-id" + private let TEST_VAL = NSNumber(value: 123.4) + + func testPutsAndPopsRecords() { + RNSentryTimeToDisplay.put(for: TEST_ID, value: TEST_VAL) + + let firstPop = RNSentryTimeToDisplay.pop(for: TEST_ID) + let secondPop = RNSentryTimeToDisplay.pop(for: TEST_ID) + + XCTAssert(firstPop == TEST_VAL) + XCTAssertNil(secondPop) + } + + func testRemovesOldestEntryWhenFull() { + let maxSize = TIME_TO_DISPLAY_ENTRIES_MAX_SIZE + 1 + for i in 1...maxSize { + RNSentryTimeToDisplay.put(for: "\(TEST_ID)-\(i)", value: NSNumber(value: i)) + } + + let oldestEntry = RNSentryTimeToDisplay.pop(for: "\(TEST_ID)-1") + let secondOldestEntry = RNSentryTimeToDisplay.pop(for: "\(TEST_ID)-2") + let newestEntry = RNSentryTimeToDisplay.pop(for: "\(TEST_ID)-\(maxSize)") + + XCTAssertNil(oldestEntry) + XCTAssertNotNil(secondOldestEntry) + XCTAssertNotNil(newestEntry) + } + + func testHandlesEarlyPoppedValues() { + let maxSize = TIME_TO_DISPLAY_ENTRIES_MAX_SIZE + 1 + for i in 1...maxSize { + let key = "\(TEST_ID)-\(i)" + RNSentryTimeToDisplay.put(for: key, value: NSNumber(value: i)) + RNSentryTimeToDisplay.pop(for: key) + } + + // Age counter reached the max size, but storage is empty + // The internal structures should handle the situation + + let nextKey1 = "\(TEST_ID)-next-1" + let nextKey2 = "\(TEST_ID)-next-2" + let nextVal1 = NSNumber(value: 123.4) + let nextVal2 = NSNumber(value: 567.8) + RNSentryTimeToDisplay.put(for: nextKey1, value: nextVal1) + RNSentryTimeToDisplay.put(for: nextKey2, value: nextVal2) + + let nextActualVal1 = RNSentryTimeToDisplay.pop(for: nextKey1) + let nextActualVal2 = RNSentryTimeToDisplay.pop(for: nextKey2) + + XCTAssertEqual(nextVal1, nextActualVal1) + XCTAssertEqual(nextVal2, nextActualVal2) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: nextKey1)) + XCTAssertNil(RNSentryTimeToDisplay.pop(for: nextKey2)) + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index edefee7730..f9e8069cb4 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -717,6 +717,14 @@ public void clearBreadcrumbs() { }); } + public void popTimeToDisplayFor(String screenId, Promise promise) { + if (screenId != null) { + promise.resolve(RNSentryTimeToDisplay.popTimeToDisplayFor(screenId)); + } else { + promise.resolve(null); + } + } + public void setExtra(String key, String extra) { if (key == null || extra == null) { logger.log( diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java index a412ab51b5..a622444658 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java @@ -3,30 +3,28 @@ import android.app.Activity; import android.content.Context; import android.view.View; -import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.common.MapBuilder; import com.facebook.react.uimanager.SimpleViewManager; import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.uimanager.annotations.ReactProp; -import com.facebook.react.uimanager.events.RCTEventEmitter; import io.sentry.ILogger; -import io.sentry.SentryDate; import io.sentry.SentryDateProvider; import io.sentry.SentryLevel; import io.sentry.android.core.AndroidLogger; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.SentryAndroidDateProvider; import io.sentry.android.core.internal.util.FirstDrawDoneListener; -import java.util.Map; +import java.util.Objects; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; public class RNSentryOnDrawReporterManager extends SimpleViewManager { public static final String REACT_CLASS = "RNSentryOnDrawReporter"; + public static final String TTID_PREFIX = "ttid-"; + public static final String TTFD_PREFIX = "ttfd-"; private final @NotNull ReactApplicationContext mCallerContext; public RNSentryOnDrawReporterManager(ReactApplicationContext reactContext) { @@ -57,12 +55,9 @@ public void setFullDisplay(RNSentryOnDrawReporterView view, boolean fullDisplay) view.setFullDisplay(fullDisplay); } - public Map getExportedCustomBubblingEventTypeConstants() { - return MapBuilder.builder() - .put( - "onDrawNextFrameView", - MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onDrawNextFrame"))) - .build(); + @ReactProp(name = "parentSpanId") + public void setParentSpanId(RNSentryOnDrawReporterView view, String parentSpanId) { + view.setParentSpanId(parentSpanId); } public static class RNSentryOnDrawReporterView extends View { @@ -71,16 +66,17 @@ public static class RNSentryOnDrawReporterView extends View { private final @Nullable ReactApplicationContext reactContext; private final @NotNull SentryDateProvider dateProvider = new SentryAndroidDateProvider(); - private final @Nullable Runnable emitInitialDisplayEvent; - private final @Nullable Runnable emitFullDisplayEvent; private final @Nullable BuildInfoProvider buildInfo; + private boolean isInitialDisplay = false; + private boolean isFullDisplay = false; + private boolean spanIdUsed = false; + private @Nullable String parentSpanId = null; + public RNSentryOnDrawReporterView(@NotNull Context context) { super(context); reactContext = null; buildInfo = null; - emitInitialDisplayEvent = null; - emitFullDisplayEvent = null; } public RNSentryOnDrawReporterView( @@ -88,35 +84,60 @@ public RNSentryOnDrawReporterView( super(context); reactContext = context; buildInfo = buildInfoProvider; - emitInitialDisplayEvent = () -> emitDisplayEvent("initialDisplay"); - emitFullDisplayEvent = () -> emitDisplayEvent("fullDisplay"); } - public void setFullDisplay(boolean fullDisplay) { - if (!fullDisplay) { - return; - } + @TestOnly + public RNSentryOnDrawReporterView( + @NotNull Context context, + @NotNull ReactApplicationContext reactContext, + @NotNull BuildInfoProvider buildInfoProvider) { + super(context); + this.reactContext = reactContext; + buildInfo = buildInfoProvider; + } - logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register full display event emitter."); - registerForNextDraw(emitFullDisplayEvent); + public void setFullDisplay(boolean newIsFullDisplay) { + if (newIsFullDisplay != isFullDisplay) { + isFullDisplay = newIsFullDisplay; + processPropsChanged(); + } } - public void setInitialDisplay(boolean initialDisplay) { - if (!initialDisplay) { - return; + public void setInitialDisplay(boolean newIsInitialDisplay) { + if (newIsInitialDisplay != isInitialDisplay) { + isInitialDisplay = newIsInitialDisplay; + processPropsChanged(); } + } - logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register initial display event emitter."); - registerForNextDraw(emitInitialDisplayEvent); + public void setParentSpanId(@Nullable String newParentSpanId) { + if (!Objects.equals(newParentSpanId, parentSpanId)) { + parentSpanId = newParentSpanId; + spanIdUsed = false; + processPropsChanged(); + } } - private void registerForNextDraw(@Nullable Runnable emitter) { - if (emitter == null) { + private void processPropsChanged() { + if (parentSpanId == null) { + return; + } + if (spanIdUsed) { logger.log( - SentryLevel.ERROR, - "[TimeToDisplay] Won't emit next frame drawn event, emitter is null."); + SentryLevel.DEBUG, + "[TimeToDisplay] Already recorded time to display for spanId: " + parentSpanId); + return; + } + + if (isInitialDisplay) { + logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register initial display event emitter."); + } else if (isFullDisplay) { + logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register full display event emitter."); + } else { + logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Not ready, missing displayType prop."); return; } + if (buildInfo == null) { logger.log( SentryLevel.ERROR, @@ -138,26 +159,36 @@ private void registerForNextDraw(@Nullable Runnable emitter) { return; } - FirstDrawDoneListener.registerForNextDraw(activity, emitter, buildInfo); + spanIdUsed = true; + registerForNextDraw( + activity, + () -> { + final Double now = dateProvider.now().nanoTimestamp() / 1e9; + if (parentSpanId == null) { + logger.log( + SentryLevel.ERROR, + "[TimeToDisplay] parentSpanId removed before frame was rendered."); + return; + } + + if (isInitialDisplay) { + RNSentryTimeToDisplay.putTimeToDisplayFor(TTID_PREFIX + parentSpanId, now); + } else if (isFullDisplay) { + RNSentryTimeToDisplay.putTimeToDisplayFor(TTFD_PREFIX + parentSpanId, now); + } else { + logger.log( + SentryLevel.DEBUG, + "[TimeToDisplay] display type removed before frame was rendered."); + } + }, + buildInfo); } - private void emitDisplayEvent(String type) { - final SentryDate endDate = dateProvider.now(); - - WritableMap event = Arguments.createMap(); - event.putString("type", type); - event.putDouble("newFrameTimestampInSeconds", endDate.nanoTimestamp() / 1e9); - - if (reactContext == null) { - logger.log( - SentryLevel.ERROR, - "[TimeToDisplay] Recorded next frame draw but can't emit the event, reactContext is" - + " null."); - return; - } - reactContext - .getJSModule(RCTEventEmitter.class) - .receiveEvent(getId(), "onDrawNextFrameView", event); + protected void registerForNextDraw( + final @NotNull Activity activity, + final @NotNull Runnable callback, + final @NotNull BuildInfoProvider buildInfo) { + FirstDrawDoneListener.registerForNextDraw(activity, callback, buildInfo); } } } diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java index b6fab45492..5060e05dac 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java @@ -6,11 +6,30 @@ import com.facebook.react.bridge.Promise; import io.sentry.SentryDate; import io.sentry.SentryDateProvider; +import java.util.LinkedHashMap; +import java.util.Map; public final class RNSentryTimeToDisplay { private RNSentryTimeToDisplay() {} + public static final int ENTRIES_MAX_SIZE = 50; + private static final Map screenIdToRenderDuration = + new LinkedHashMap(ENTRIES_MAX_SIZE + 1, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > ENTRIES_MAX_SIZE; + } + }; + + public static Double popTimeToDisplayFor(String screenId) { + return screenIdToRenderDuration.remove(screenId); + } + + public static void putTimeToDisplayFor(String screenId, Double value) { + screenIdToRenderDuration.put(screenId, value); + } + public static void getTimeToDisplay(Promise promise, SentryDateProvider dateProvider) { Looper mainLooper = Looper.getMainLooper(); diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 92ef2c0614..4ab2241fa5 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -182,4 +182,9 @@ public void getNewScreenTimeToDisplay(Promise promise) { public void getDataFromUri(String uri, Promise promise) { this.impl.getDataFromUri(uri, promise); } + + @Override + public void popTimeToDisplayFor(String key, Promise promise) { + this.impl.popTimeToDisplayFor(key, promise); + } } diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index de7f82be35..38691c725b 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -961,4 +961,12 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys [_timeToDisplay getTimeToDisplay:resolve]; } +RCT_EXPORT_METHOD(popTimeToDisplayFor + : (NSString *)key resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) +{ + resolve([RNSentryTimeToDisplay popTimeToDisplayFor:key]); +} + @end diff --git a/packages/core/ios/RNSentryFramesTrackerListener.h b/packages/core/ios/RNSentryFramesTrackerListener.h index e0de09dfd9..627b3059f4 100644 --- a/packages/core/ios/RNSentryFramesTrackerListener.h +++ b/packages/core/ios/RNSentryFramesTrackerListener.h @@ -8,13 +8,17 @@ typedef void (^RNSentryEmitNewFrameEvent)(NSNumber *newFrameTimestampInSeconds); -@interface RNSentryFramesTrackerListener : NSObject +@protocol RNSentryFramesTrackerListenerProtocol + +- (void)startListening; + +@end + +@interface RNSentryFramesTrackerListener : NSObject - (instancetype)initWithSentryFramesTracker:(SentryFramesTracker *)framesTracker andEventEmitter:(RNSentryEmitNewFrameEvent)emitNewFrameEvent; -- (void)startListening; - @property (strong, nonatomic) SentryFramesTracker *framesTracker; @property (strong, nonatomic) RNSentryEmitNewFrameEvent emitNewFrameEvent; diff --git a/packages/core/ios/RNSentryOnDrawReporter.h b/packages/core/ios/RNSentryOnDrawReporter.h index 1cf9fb6245..5c4083015d 100644 --- a/packages/core/ios/RNSentryOnDrawReporter.h +++ b/packages/core/ios/RNSentryOnDrawReporter.h @@ -12,11 +12,15 @@ @interface RNSentryOnDrawReporterView : UIView -@property (nonatomic, strong) RNSentryFramesTrackerListener *framesListener; -@property (nonatomic, copy) RCTBubblingEventBlock onDrawNextFrame; +@property (nonatomic, strong) id framesListener; @property (nonatomic) bool fullDisplay; @property (nonatomic) bool initialDisplay; +@property (nonatomic) bool spanIdUsed; +@property (nonatomic, copy) NSString *parentSpanId; @property (nonatomic, weak) RNSentryOnDrawReporter *delegate; +@property (nonatomic) bool previousFullDisplay; +@property (nonatomic) bool previousInitialDisplay; +@property (nonatomic, copy) NSString *previousParentSpanId; @end diff --git a/packages/core/ios/RNSentryOnDrawReporter.m b/packages/core/ios/RNSentryOnDrawReporter.m index d8266c73a5..85b2ee98ec 100644 --- a/packages/core/ios/RNSentryOnDrawReporter.m +++ b/packages/core/ios/RNSentryOnDrawReporter.m @@ -1,4 +1,5 @@ #import "RNSentryOnDrawReporter.h" +#import "RNSentryTimeToDisplay.h" #if SENTRY_HAS_UIKIT @@ -7,9 +8,9 @@ @implementation RNSentryOnDrawReporter RCT_EXPORT_MODULE(RNSentryOnDrawReporter) -RCT_EXPORT_VIEW_PROPERTY(onDrawNextFrame, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(initialDisplay, BOOL) RCT_EXPORT_VIEW_PROPERTY(fullDisplay, BOOL) +RCT_EXPORT_VIEW_PROPERTY(parentSpanId, NSString) - (UIView *)view { @@ -19,29 +20,16 @@ - (UIView *)view @end -@implementation RNSentryOnDrawReporterView +@implementation RNSentryOnDrawReporterView { + BOOL isListening; +} - (instancetype)init { self = [super init]; if (self) { - RNSentryEmitNewFrameEvent emitNewFrameEvent = ^(NSNumber *newFrameTimestampInSeconds) { - if (self->_fullDisplay) { - self.onDrawNextFrame(@{ - @"newFrameTimestampInSeconds" : newFrameTimestampInSeconds, - @"type" : @"fullDisplay" - }); - return; - } - - if (self->_initialDisplay) { - self.onDrawNextFrame(@{ - @"newFrameTimestampInSeconds" : newFrameTimestampInSeconds, - @"type" : @"initialDisplay" - }); - return; - } - }; + _spanIdUsed = NO; + RNSentryEmitNewFrameEvent emitNewFrameEvent = [self createEmitNewFrameEvent]; _framesListener = [[RNSentryFramesTrackerListener alloc] initWithSentryFramesTracker:[[SentryDependencyContainer sharedInstance] framesTracker] andEventEmitter:emitNewFrameEvent]; @@ -49,10 +37,49 @@ - (instancetype)init return self; } +- (RNSentryEmitNewFrameEvent)createEmitNewFrameEvent +{ + return ^(NSNumber *newFrameTimestampInSeconds) { + self->isListening = NO; + + if (self->_fullDisplay) { + [RNSentryTimeToDisplay + putTimeToDisplayFor:[@"ttfd-" stringByAppendingString:self->_parentSpanId] + value:newFrameTimestampInSeconds]; + return; + } + + if (self->_initialDisplay) { + [RNSentryTimeToDisplay + putTimeToDisplayFor:[@"ttid-" stringByAppendingString:self->_parentSpanId] + value:newFrameTimestampInSeconds]; + return; + } + }; +} + - (void)didSetProps:(NSArray *)changedProps { + if (![_parentSpanId isKindOfClass:[NSString class]]) { + _previousParentSpanId = nil; + return; + } + + if ([_parentSpanId isEqualToString:_previousParentSpanId] && _spanIdUsed) { + _previousInitialDisplay = _initialDisplay; + _previousFullDisplay = _fullDisplay; + return; + } + + _previousParentSpanId = _parentSpanId; + _spanIdUsed = NO; + if (_fullDisplay || _initialDisplay) { - [_framesListener startListening]; + if (!isListening && !_spanIdUsed) { + _spanIdUsed = YES; + isListening = YES; + [_framesListener startListening]; + } } } diff --git a/packages/core/ios/RNSentryTimeToDisplay.h b/packages/core/ios/RNSentryTimeToDisplay.h index fbb468cb23..434e034b93 100644 --- a/packages/core/ios/RNSentryTimeToDisplay.h +++ b/packages/core/ios/RNSentryTimeToDisplay.h @@ -1,7 +1,12 @@ #import +static const int TIME_TO_DISPLAY_ENTRIES_MAX_SIZE = 50; + @interface RNSentryTimeToDisplay : NSObject ++ (NSNumber *)popTimeToDisplayFor:(NSString *)screenId; ++ (void)putTimeToDisplayFor:(NSString *)screenId value:(NSNumber *)value; + - (void)getTimeToDisplay:(RCTResponseSenderBlock)callback; @end diff --git a/packages/core/ios/RNSentryTimeToDisplay.m b/packages/core/ios/RNSentryTimeToDisplay.m index 9404cf5088..dbf2a07ee1 100644 --- a/packages/core/ios/RNSentryTimeToDisplay.m +++ b/packages/core/ios/RNSentryTimeToDisplay.m @@ -7,6 +7,57 @@ @implementation RNSentryTimeToDisplay { RCTResponseSenderBlock resolveBlock; } +static NSMutableDictionary *screenIdToRenderDuration; +static NSMutableArray *screenIdAge; +static NSUInteger screenIdCurrentIndex; + ++ (void)initialize +{ + if (self == [RNSentryTimeToDisplay class]) { + screenIdToRenderDuration = + [[NSMutableDictionary alloc] initWithCapacity:TIME_TO_DISPLAY_ENTRIES_MAX_SIZE]; + screenIdAge = [[NSMutableArray alloc] initWithCapacity:TIME_TO_DISPLAY_ENTRIES_MAX_SIZE]; + screenIdCurrentIndex = 0; + } +} + ++ (NSNumber *)popTimeToDisplayFor:(NSString *)screenId +{ + NSNumber *value = screenIdToRenderDuration[screenId]; + [screenIdToRenderDuration removeObjectForKey:screenId]; + return value; +} + ++ (void)putTimeToDisplayFor:(NSString *)screenId value:(NSNumber *)value +{ + if (!screenId) + return; + + // If key already exists, just update the value, + // this should never happen as TTD is recorded once per navigation + // We avoid updating the age to avoid the age array shift + if ([screenIdToRenderDuration objectForKey:screenId]) { + [screenIdToRenderDuration setObject:value forKey:screenId]; + return; + } + + // If we haven't reached capacity yet, just append + if (screenIdAge.count < TIME_TO_DISPLAY_ENTRIES_MAX_SIZE) { + [screenIdToRenderDuration setObject:value forKey:screenId]; + [screenIdAge addObject:screenId]; + } else { + // Remove oldest entry, in most case will already be removed by pop + NSString *oldestKey = screenIdAge[screenIdCurrentIndex]; + [screenIdToRenderDuration removeObjectForKey:oldestKey]; + + [screenIdToRenderDuration setObject:value forKey:screenId]; + screenIdAge[screenIdCurrentIndex] = screenId; + + // Update circular index, point to the new oldest + screenIdCurrentIndex = (screenIdCurrentIndex + 1) % TIME_TO_DISPLAY_ENTRIES_MAX_SIZE; + } +} + // Rename requestAnimationFrame to getTimeToDisplay - (void)getTimeToDisplay:(RCTResponseSenderBlock)callback { diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 125dc3b082..cc69f081c3 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -49,6 +49,7 @@ export interface Spec extends TurboModule { getCurrentReplayId(): string | undefined | null; crashedLastRun(): Promise; getDataFromUri(uri: string): Promise; + popTimeToDisplayFor(key: string): Promise; } export type NativeStackFrame = { diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index 71ac905afb..54b4065ee5 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -33,6 +33,7 @@ import { sdkInfoIntegration, spotlightIntegration, stallTrackingIntegration, + timeToDisplayIntegration, userInteractionIntegration, viewHierarchyIntegration, } from './exports'; @@ -115,6 +116,9 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(appRegistryIntegration()); integrations.push(reactNativeTracingIntegration()); } + if (hasTracingEnabled) { + integrations.push(timeToDisplayIntegration()); + } if (options.enableCaptureFailedRequests) { integrations.push(httpClientIntegration()); } diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index 695b323c38..b889ec3716 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -21,6 +21,7 @@ export { stallTrackingIntegration } from '../tracing/integrations/stalltracking' export { userInteractionIntegration } from '../tracing/integrations/userInteraction'; export { createReactNativeRewriteFrames } from './rewriteframes'; export { appRegistryIntegration } from './appRegistry'; +export { timeToDisplayIntegration } from '../tracing/integrations/timeToDisplayIntegration'; export { breadcrumbsIntegration, diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts new file mode 100644 index 0000000000..0efe086b80 --- /dev/null +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -0,0 +1,170 @@ +import type { Event, Integration, SpanJSON } from '@sentry/core'; +import { logger } from '@sentry/core'; + +import { NATIVE } from '../../wrapper'; +import { UI_LOAD_FULL_DISPLAY, UI_LOAD_INITIAL_DISPLAY } from '../ops'; +import { SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../origin'; +import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../span'; +import { createSpanJSON } from '../utils'; +export const INTEGRATION_NAME = 'TimeToDisplay'; + +const TIME_TO_DISPLAY_TIMEOUT_MS = 30_000; +const isDeadlineExceeded = (durationMs: number): boolean => durationMs > TIME_TO_DISPLAY_TIMEOUT_MS; + +export const timeToDisplayIntegration = (): Integration => { + return { + name: INTEGRATION_NAME, + processEvent: async event => { + if (event.type !== 'transaction') { + // TimeToDisplay data is only relevant for transactions + return event; + } + + const rootSpanId = event.contexts.trace.span_id; + if (!rootSpanId) { + logger.warn(`[${INTEGRATION_NAME}] No root span id found in transaction.`); + return event; + } + + const transactionStartTimestampSeconds = event.start_timestamp; + if (!transactionStartTimestampSeconds) { + // This should never happen + logger.warn(`[${INTEGRATION_NAME}] No transaction start timestamp found in transaction.`); + return event; + } + + event.spans = event.spans || []; + event.measurements = event.measurements || {}; + + const ttidSpan = await addTimeToInitialDisplay({ event, rootSpanId, transactionStartTimestampSeconds }); + const ttfdSpan = await addTimeToFullDisplay({ event, rootSpanId, transactionStartTimestampSeconds, ttidSpan }); + + if (ttidSpan && ttidSpan.start_timestamp && ttidSpan.timestamp) { + event.measurements['time_to_initial_display'] = { + value: (ttidSpan.timestamp - ttidSpan.start_timestamp) * 1000, + unit: 'millisecond', + }; + } + + if (ttfdSpan && ttfdSpan.start_timestamp && ttfdSpan.timestamp) { + const durationMs = (ttfdSpan.timestamp - ttfdSpan.start_timestamp) * 1000; + if (isDeadlineExceeded(durationMs)) { + event.measurements['time_to_full_display'] = event.measurements['time_to_initial_display']; + } else { + event.measurements['time_to_full_display'] = { + value: durationMs, + unit: 'millisecond', + }; + } + } + + const newTransactionEndTimestampSeconds = Math.max( + ttidSpan?.timestamp ?? -1, + ttfdSpan?.timestamp ?? -1, + event.timestamp ?? -1, + ); + if (newTransactionEndTimestampSeconds !== -1) { + event.timestamp = newTransactionEndTimestampSeconds; + } + + return event; + }, + }; +}; + +async function addTimeToInitialDisplay({ + event, + rootSpanId, + transactionStartTimestampSeconds, +}: { + event: Event; + rootSpanId: string; + transactionStartTimestampSeconds: number; +}): Promise { + const ttidEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttid-${rootSpanId}`); + + let ttidSpan: SpanJSON | undefined = event.spans?.find(span => span.op === UI_LOAD_INITIAL_DISPLAY); + + if (ttidSpan && (ttidSpan.status === undefined || ttidSpan.status === 'ok') && !ttidEndTimestampSeconds) { + logger.debug(`[${INTEGRATION_NAME}] Ttid span already exists and is ok.`, ttidSpan); + return ttidSpan; + } + + if (!ttidEndTimestampSeconds) { + logger.debug(`[${INTEGRATION_NAME}] No ttid end timestamp found for span ${rootSpanId}.`); + return undefined; + } + + if (ttidSpan && ttidSpan.status && ttidSpan.status !== 'ok') { + ttidSpan.status = 'ok'; + ttidSpan.timestamp = ttidEndTimestampSeconds; + logger.debug(`[${INTEGRATION_NAME}] Updated existing ttid span.`, ttidSpan); + return ttidSpan; + } + + ttidSpan = createSpanJSON({ + op: UI_LOAD_INITIAL_DISPLAY, + description: 'Time To Initial Display', + start_timestamp: transactionStartTimestampSeconds, + timestamp: ttidEndTimestampSeconds, + origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + parent_span_id: rootSpanId, + data: { + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, + }, + }); + logger.debug(`[${INTEGRATION_NAME}] Added ttid span to transaction.`, ttidSpan); + event.spans.push(ttidSpan); + return ttidSpan; +} + +async function addTimeToFullDisplay({ + event, + rootSpanId, + transactionStartTimestampSeconds, + ttidSpan, +}: { + event: Event; + rootSpanId: string; + transactionStartTimestampSeconds: number; + ttidSpan: SpanJSON | undefined; +}): Promise { + const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`); + + if (!ttidSpan || !ttfdEndTimestampSeconds) { + return undefined; + } + + let ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY); + + let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds; + const ttfdIsBeforeTtid = ttidSpan?.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp; + if (ttfdIsBeforeTtid) { + ttfdAdjustedEndTimestampSeconds = ttidSpan.timestamp; + } + + const durationMs = (ttfdAdjustedEndTimestampSeconds - transactionStartTimestampSeconds) * 1000; + + if (ttfdSpan && ttfdSpan.status && ttfdSpan.status !== 'ok') { + ttfdSpan.status = 'ok'; + ttfdSpan.timestamp = ttfdAdjustedEndTimestampSeconds; + logger.debug(`[${INTEGRATION_NAME}] Updated existing ttfd span.`, ttfdSpan); + return ttfdSpan; + } + + ttfdSpan = createSpanJSON({ + status: isDeadlineExceeded(durationMs) ? 'deadline_exceeded' : 'ok', + op: UI_LOAD_FULL_DISPLAY, + description: 'Time To Full Display', + start_timestamp: transactionStartTimestampSeconds, + timestamp: ttfdAdjustedEndTimestampSeconds, + origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + parent_span_id: rootSpanId, + data: { + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, + }, + }); + logger.debug(`[${INTEGRATION_NAME}] Added ttfd span to transaction.`, ttfdSpan); + event.spans.push(ttfdSpan); + return ttfdSpan; +} diff --git a/packages/core/src/js/tracing/ops.ts b/packages/core/src/js/tracing/ops.ts index 0f574d89b9..79c7c239b1 100644 --- a/packages/core/src/js/tracing/ops.ts +++ b/packages/core/src/js/tracing/ops.ts @@ -7,3 +7,6 @@ export const UI_ACTION_TOUCH = 'ui.action.touch'; export const APP_START_COLD = 'app.start.cold'; export const APP_START_WARM = 'app.start.warm'; + +export const UI_LOAD_INITIAL_DISPLAY = 'ui.load.initial_display'; +export const UI_LOAD_FULL_DISPLAY = 'ui.load.full_display'; diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 0c9c97d8ab..f33216b875 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -6,7 +6,6 @@ import { useState } from 'react'; import { isTurboModuleEnabled } from '../utils/environment'; import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin'; import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative'; -import type { RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types'; import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils'; let nativeComponentMissingLogged = false; @@ -37,10 +36,10 @@ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElem const activeSpan = getActiveSpan(); if (activeSpan) { manualInitialDisplaySpans.set(activeSpan, true); - startTimeToInitialDisplaySpan(); } - return {props.children}; + const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id; + return {props.children}; } /** @@ -51,14 +50,16 @@ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElem * */ export function TimeToFullDisplay(props: TimeToDisplayProps): React.ReactElement { - startTimeToFullDisplaySpan(); - return {props.children}; + const activeSpan = getActiveSpan(); + const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id; + return {props.children}; } function TimeToDisplay(props: { children?: React.ReactNode; initialDisplay?: boolean; fullDisplay?: boolean; + parentSpanId?: string; }): React.ReactElement { const RNSentryOnDrawReporter = getRNSentryOnDrawReporter(); const isNewArchitecture = isTurboModuleEnabled(); @@ -72,14 +73,12 @@ function TimeToDisplay(props: { }, 0); } - const onDraw = (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }): void => onDrawNextFrame(event); - return ( <> + fullDisplay={props.fullDisplay} + parentSpanId={props.parentSpanId} /> {props.children} ); @@ -89,6 +88,8 @@ function TimeToDisplay(props: { * Starts a new span for the initial display. * * Returns current span if already exists in the currently active span. + * + * @deprecated Use `` component instead. */ export function startTimeToInitialDisplaySpan( options?: Omit & { @@ -133,6 +134,8 @@ export function startTimeToInitialDisplaySpan( * Starts a new span for the full display. * * Returns current span if already exists in the currently active span. + * + * @deprecated Use `` component instead. */ export function startTimeToFullDisplaySpan( options: Omit & { @@ -197,16 +200,6 @@ export function startTimeToFullDisplaySpan( return fullDisplaySpan; } -function onDrawNextFrame(event: { nativeEvent: RNSentryOnDrawNextFrameEvent }): void { - logger.debug(`[TimeToDisplay] onDrawNextFrame: ${JSON.stringify(event.nativeEvent)}`); - if (event.nativeEvent.type === 'fullDisplay') { - return updateFullDisplaySpan(event.nativeEvent.newFrameTimestampInSeconds); - } - if (event.nativeEvent.type === 'initialDisplay') { - return updateInitialDisplaySpan(event.nativeEvent.newFrameTimestampInSeconds); - } -} - /** * */ diff --git a/packages/core/src/js/tracing/timetodisplaynative.types.ts b/packages/core/src/js/tracing/timetodisplaynative.types.ts index 85fbf5b4a2..ce6c90fe68 100644 --- a/packages/core/src/js/tracing/timetodisplaynative.types.ts +++ b/packages/core/src/js/tracing/timetodisplaynative.types.ts @@ -5,7 +5,7 @@ export interface RNSentryOnDrawNextFrameEvent { export interface RNSentryOnDrawReporterProps { children?: React.ReactNode; - onDrawNextFrame: (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }) => void; initialDisplay?: boolean; fullDisplay?: boolean; + parentSpanId?: string; } diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 6ec2f84643..dcbd8d38cf 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -122,6 +122,7 @@ interface SentryNativeWrapper { getNewScreenTimeToDisplay(): Promise; getDataFromUri(uri: string): Promise; + popTimeToDisplayFor(key: string): Promise; } const EOL = utf8ToBytes('\n'); @@ -717,6 +718,19 @@ export const NATIVE: SentryNativeWrapper = { } }, + popTimeToDisplayFor(key: string): Promise { + if (!this.enableNative || !this._isModuleLoaded(RNSentry)) { + return Promise.resolve(null); + } + + try { + return RNSentry.popTimeToDisplayFor(key); + } catch (error) { + logger.error('Error:', error); + return null; + } + }, + /** * 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/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index fe6a611394..923a6becfc 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -60,6 +60,7 @@ const NATIVE: MockInterface = { crashedLastRun: jest.fn(), getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42), getDataFromUri: jest.fn(), + popTimeToDisplayFor: jest.fn(), }; NATIVE.isNativeAvailable.mockReturnValue(true); @@ -84,6 +85,7 @@ NATIVE.initNativeReactNavigationNewFrameTracking.mockReturnValue(Promise.resolve NATIVE.captureReplay.mockResolvedValue(null); NATIVE.getCurrentReplayId.mockReturnValue(null); NATIVE.crashedLastRun.mockResolvedValue(false); +NATIVE.popTimeToDisplayFor.mockResolvedValue(null); export const getRNSentryModule = jest.fn(); diff --git a/packages/core/test/sdk.test.ts b/packages/core/test/sdk.test.ts index 330652b284..268dc742f2 100644 --- a/packages/core/test/sdk.test.ts +++ b/packages/core/test/sdk.test.ts @@ -743,6 +743,22 @@ describe('Tests the SDK functionality', () => { }); }); + describe('time to display integration', () => { + it('no integration when tracing disabled', () => { + init({}); + + expectNotIntegration('TimeToDisplay'); + }); + + it('integration added when tracing enabled', () => { + init({ + tracesSampleRate: 0.5, + }); + + expectIntegration('TimeToDisplay'); + }); + }); + it('adds spotlight integration with spotlight bool', () => { init({ spotlight: true, diff --git a/packages/core/test/testutils.ts b/packages/core/test/testutils.ts index 76bdd990a6..bf0aa9f3d7 100644 --- a/packages/core/test/testutils.ts +++ b/packages/core/test/testutils.ts @@ -62,6 +62,10 @@ export const createMockTransport = (): MockInterface => { }; }; +export const nowInSeconds = (): number => { + return Date.now() / 1000; +}; + export const secondAgoTimestampMs = (): number => { return new Date(Date.now() - 1000).getTime(); }; diff --git a/packages/core/test/tracing/mockedtimetodisplaynative.tsx b/packages/core/test/tracing/mockedtimetodisplaynative.tsx index 14c78fc5e0..886ff1cad0 100644 --- a/packages/core/test/tracing/mockedtimetodisplaynative.tsx +++ b/packages/core/test/tracing/mockedtimetodisplaynative.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { View } from 'react-native'; -import type { RNSentryOnDrawNextFrameEvent, RNSentryOnDrawReporterProps } from '../../src/js/tracing/timetodisplaynative.types'; +import type { RNSentryOnDrawReporterProps } from '../../src/js/tracing/timetodisplaynative.types'; +import { NATIVE } from '../mockWrapper'; export let nativeComponentExists = true; @@ -9,18 +10,34 @@ export function setMockedNativeComponentExists(value: boolean): void { nativeComponentExists = value; } -export let mockedOnDrawNextFrame: (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }) => void; +/** + * { + * [spanId]: timestampInSeconds, + * } + */ +export function mockRecordedTimeToDisplay({ ttid = {}, ttfd = {} }: { ttid?: Record, ttfd?: Record }): void { + NATIVE.popTimeToDisplayFor.mockImplementation((key: string) => { + if (key.startsWith('ttid-')) { + return Promise.resolve(ttid[key.substring(5)]); + } else if (key.startsWith('ttfd-')) { + return Promise.resolve(ttfd[key.substring(5)]); + } + return Promise.resolve(undefined); + }); +} + +let mockedProps: RNSentryOnDrawReporterProps[] = []; -export function emitNativeInitialDisplayEvent(frameTimestampMs?: number): void { - mockedOnDrawNextFrame({ nativeEvent: { type: 'initialDisplay', newFrameTimestampInSeconds: (frameTimestampMs || Date.now()) / 1_000 } }); +export function getMockedOnDrawReportedProps(): RNSentryOnDrawReporterProps[] { + return mockedProps; } -export function emitNativeFullDisplayEvent(frameTimestampMs?: number): void { - mockedOnDrawNextFrame({ nativeEvent: { type: 'fullDisplay', newFrameTimestampInSeconds: (frameTimestampMs || Date.now()) / 1_000 } }); +export function clearMockedOnDrawReportedProps(): void { + mockedProps = []; } function RNSentryOnDrawReporterMock(props: RNSentryOnDrawReporterProps): React.ReactElement { - mockedOnDrawNextFrame = props.onDrawNextFrame; + mockedProps.push(props); return ; } diff --git a/packages/core/test/tracing/reactnavigation.ttid.test.tsx b/packages/core/test/tracing/reactnavigation.ttid.test.tsx index f134b5b8f8..4efe622a25 100644 --- a/packages/core/test/tracing/reactnavigation.ttid.test.tsx +++ b/packages/core/test/tracing/reactnavigation.ttid.test.tsx @@ -1,5 +1,5 @@ import type { Scope, Span, SpanJSON, TransactionEvent, Transport } from '@sentry/core'; -import { timestampInSeconds } from '@sentry/core'; +import { getActiveSpan, spanToJSON, timestampInSeconds } from '@sentry/core'; import * as TestRenderer from '@testing-library/react-native' import * as React from "react"; @@ -21,9 +21,9 @@ import { isHermesEnabled, notWeb } from '../../src/js/utils/environment'; import { createSentryFallbackEventEmitter } from '../../src/js/utils/sentryeventemitterfallback'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { MOCK_DSN } from '../mockDsn'; -import { secondInFutureTimestampMs } from '../testutils'; +import { nowInSeconds, secondInFutureTimestampMs } from '../testutils'; import type { MockedSentryEventEmitterFallback } from '../utils/mockedSentryeventemitterfallback'; -import { emitNativeFullDisplayEvent, emitNativeInitialDisplayEvent } from './mockedtimetodisplaynative'; +import { mockRecordedTimeToDisplay } from './mockedtimetodisplaynative'; import { createMockNavigationAndAttachTo } from './reactnavigationutils'; const SCOPE_SPAN_FIELD = '_sentrySpan'; @@ -286,7 +286,11 @@ describe('React Navigation - TTID', () => { mockedEventEmitter.emitNewFrameEvent(); TestRenderer.render(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(getActiveSpan()!).span_id!]: nowInSeconds(), + }, + }); jest.runOnlyPendingTimers(); // Flush ttid transaction @@ -356,7 +360,11 @@ describe('React Navigation - TTID', () => { mockedNavigation.navigateToNewScreen(); TestRenderer.render(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds() - 1, + }, + }); mockedEventEmitter.emitNewFrameEvent(); jest.runOnlyPendingTimers(); // Flush navigation transaction @@ -378,7 +386,11 @@ describe('React Navigation - TTID', () => { mockedEventEmitter.emitNewFrameEvent(); TestRenderer.render(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds(), + }, + }); jest.runOnlyPendingTimers(); // Flush ttid transaction @@ -481,7 +493,11 @@ describe('React Navigation - TTID', () => { mockedEventEmitter.emitNewFrameEvent(); timeToDisplayComponent.update(); - emitNativeInitialDisplayEvent(manualInitialDisplayEndTimestampMs); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(getActiveSpan()!).span_id!]: manualInitialDisplayEndTimestampMs / 1_000, + }, + }); jest.runOnlyPendingTimers(); // Flush transaction @@ -515,7 +531,7 @@ describe('React Navigation - TTID', () => { // Initialized too late auto instrumentation finished before manual TestRenderer.render(); - emitNativeInitialDisplayEvent(secondInFutureTimestampMs()); + // mockRecordedInitialDisplayAt(secondInFutureTimestampMs()); jest.runOnlyPendingTimers(); // Flush transaction @@ -703,6 +719,7 @@ function initSentry(sut: ReturnType): integrations: [ sut, Sentry.reactNativeTracingIntegration(), + Sentry.timeToDisplayIntegration(), ], transport: () => ({ send: transportSendMock.mockResolvedValue({}), diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index bfdbd3baa5..5bd812651f 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -1,6 +1,9 @@ -import { getActiveSpan, getCurrentScope, getGlobalScope, getIsolationScope, getSpanDescendants, logger , setCurrentClient, spanToJSON, startSpanManual} from '@sentry/core'; +import { getCurrentScope, getGlobalScope, getIsolationScope, logger , setCurrentClient, spanToJSON, startSpanManual } from '@sentry/core'; jest.spyOn(logger, 'warn'); +import * as mockWrapper from '../mockWrapper'; +jest.mock('../../src/js/wrapper', () => mockWrapper); + import * as mockedtimetodisplaynative from './mockedtimetodisplaynative'; jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); @@ -13,12 +16,15 @@ import type { Event, Measurements, Span, SpanJSON} from '@sentry/core'; import * as React from "react"; import * as TestRenderer from 'react-test-renderer'; +import { timeToDisplayIntegration } from '../../src/js/tracing/integrations/timeToDisplayIntegration'; import { SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../../src/js/tracing/origin'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../src/js/tracing/semanticAttributes'; +import { SPAN_THREAD_NAME , SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; import { startTimeToFullDisplaySpan, startTimeToInitialDisplaySpan, TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing/timetodisplay'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; -import { secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils'; -import { emitNativeFullDisplayEvent, emitNativeInitialDisplayEvent } from './mockedtimetodisplaynative'; +import { nowInSeconds, secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils'; + +const { mockRecordedTimeToDisplay, getMockedOnDrawReportedProps, clearMockedOnDrawReportedProps } = mockedtimetodisplaynative; jest.useFakeTimers({advanceTimers: true}); @@ -26,6 +32,7 @@ describe('TimeToDisplay', () => { let client: TestClient; beforeEach(() => { + clearMockedOnDrawReportedProps(); getCurrentScope().clear(); getIsolationScope().clear(); getGlobalScope().clear(); @@ -33,7 +40,13 @@ describe('TimeToDisplay', () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, }); - client = new TestClient(options); + client = new TestClient({ + ...options, + integrations: [ + ...options.integrations, + timeToDisplayIntegration(), + ], + }); setCurrentClient(client); client.init(); }); @@ -43,20 +56,21 @@ describe('TimeToDisplay', () => { }); test('creates manual initial display', async () => { - const [testSpan, activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { - const testSpan = startTimeToInitialDisplaySpan(); + startTimeToInitialDisplaySpan(); TestRenderer.create(); - - emitNativeInitialDisplayEvent(); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - - return [testSpan, activeSpan]; }, ); @@ -64,83 +78,33 @@ describe('TimeToDisplay', () => { await client.flush(); expectInitialDisplayMeasurementOnSpan(client.event!); - expectFinishedInitialDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); + expectFinishedInitialDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('creates manual full display', async () => { - const [testSpan, activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { startTimeToInitialDisplaySpan(); - const testSpan = startTimeToFullDisplaySpan(); + startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeInitialDisplayEvent(); - TestRenderer.create(); - emitNativeFullDisplayEvent(); - - activeSpan?.end(); - return [testSpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectFullDisplayMeasurementOnSpan(client.event!); - expectFinishedFullDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); - }); - - test('creates initial display span on first component render', async () => { - const [testSpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - const renderer = TestRenderer.create(); - const testSpan = getInitialDisplaySpan(activeSpan); - - renderer.update(); - emitNativeInitialDisplayEvent(); - activeSpan?.end(); - return [testSpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectInitialDisplayMeasurementOnSpan(client.event!); - expectFinishedInitialDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); - }); - - test('creates full display span on first component render', async () => { - const [testSpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - TestRenderer.create(); - emitNativeInitialDisplayEvent(); - - const renderer = TestRenderer.create(); - const testSpan = getFullDisplaySpan(getActiveSpan()); - - renderer.update(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [testSpan, activeSpan]; }, ); @@ -148,12 +112,13 @@ describe('TimeToDisplay', () => { await client.flush(); expectFullDisplayMeasurementOnSpan(client.event!); - expectFinishedFullDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); + expectFinishedFullDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); + expect(getMockedOnDrawReportedProps()[1]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('does not create full display when initial display is missing', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -162,10 +127,13 @@ describe('TimeToDisplay', () => { startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); @@ -175,11 +143,13 @@ describe('TimeToDisplay', () => { expectNoInitialDisplayMeasurementOnSpan(client.event!); expectNoFullDisplayMeasurementOnSpan(client.event!); - expectNoTimeToDisplaySpans(activeSpan); + expectNoTimeToDisplaySpans(client.event!); + + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('creates initial display for active span without initial display span', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -187,10 +157,13 @@ describe('TimeToDisplay', () => { (activeSpan: Span | undefined) => { TestRenderer.create(); - emitNativeInitialDisplayEvent(); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); @@ -198,11 +171,12 @@ describe('TimeToDisplay', () => { await client.flush(); expectInitialDisplayMeasurementOnSpan(client.event!); - expectFinishedInitialDisplaySpan(getInitialDisplaySpan(activeSpan), activeSpan); + expectFinishedInitialDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('creates full display for active span without full display span', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -212,13 +186,18 @@ describe('TimeToDisplay', () => { startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeInitialDisplayEvent(); - TestRenderer.create(); - emitNativeFullDisplayEvent(); + + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); @@ -226,11 +205,13 @@ describe('TimeToDisplay', () => { await client.flush(); expectFullDisplayMeasurementOnSpan(client.event!); - expectFinishedFullDisplaySpan(getFullDisplaySpan(activeSpan), activeSpan); + expectFinishedFullDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); + expect(getMockedOnDrawReportedProps()[1]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('cancels full display spans longer than 30s', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -240,23 +221,26 @@ describe('TimeToDisplay', () => { startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeInitialDisplayEvent(); - TestRenderer.create(); - // native event is not emitted - jest.advanceTimersByTime(40_000); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds() + 40, + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); await jest.runOnlyPendingTimersAsync(); await client.flush(); - expectFinishedInitialDisplaySpan(getInitialDisplaySpan(activeSpan), activeSpan); - expectDeadlineExceededFullDisplaySpan(getFullDisplaySpan(activeSpan), activeSpan); + expectFinishedInitialDisplaySpan(client.event!); + expectDeadlineExceededFullDisplaySpan(client.event!); expectInitialDisplayMeasurementOnSpan(client.event!); expectFullDisplayMeasurementOnSpan(client.event!); @@ -267,120 +251,46 @@ describe('TimeToDisplay', () => { test('full display which ended before initial display is extended to initial display end', async () => { const fullDisplayEndTimestampMs = secondInFutureTimestampMs(); const initialDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; - const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { - const initialDisplaySpan = startTimeToInitialDisplaySpan(); - const fullDisplaySpan = startTimeToFullDisplaySpan(); - - const timeToDisplayComponent = TestRenderer.create(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs); - - timeToDisplayComponent.update(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs + 10); - emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs); - - activeSpan?.end(); - return [initialDisplaySpan, fullDisplaySpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); - expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); - - expectInitialDisplayMeasurementOnSpan(client.event!); - expectFullDisplayMeasurementOnSpan(client.event!); - - expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - }); - - test('full display which ended before but processed after initial display is extended to initial display end', async () => { - const fullDisplayEndTimestampMs = secondInFutureTimestampMs(); - const initialDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; - const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - const initialDisplaySpan = startTimeToInitialDisplaySpan(); - const fullDisplaySpan = startTimeToFullDisplaySpan(); + startTimeToInitialDisplaySpan(); + startTimeToFullDisplaySpan(); const timeToDisplayComponent = TestRenderer.create(<>); - timeToDisplayComponent.update(<>); - - emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs); - - activeSpan?.end(); - return [initialDisplaySpan, fullDisplaySpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); - expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); - - expectInitialDisplayMeasurementOnSpan(client.event!); - expectFullDisplayMeasurementOnSpan(client.event!); - - expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - }); - - test('consequent renders do not update display end', async () => { - const initialDisplayEndTimestampMs = secondInFutureTimestampMs(); - const fullDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; - const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - const initialDisplaySpan = startTimeToInitialDisplaySpan(); - const fullDisplaySpan = startTimeToFullDisplaySpan(); - - const timeToDisplayComponent = TestRenderer.create(<>); - emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs); - - timeToDisplayComponent.update(<>); - emitNativeInitialDisplayEvent(fullDisplayEndTimestampMs + 10); timeToDisplayComponent.update(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs); - timeToDisplayComponent.update(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs + 20); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(activeSpan!).span_id!]: fullDisplayEndTimestampMs / 1_000, + }, + ttid: { + [spanToJSON(activeSpan!).span_id!]: initialDisplayEndTimestampMs / 1_000, + }, + }); activeSpan?.end(); - return [initialDisplaySpan, fullDisplaySpan, activeSpan]; }, ); await jest.runOnlyPendingTimersAsync(); await client.flush(); - expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); - expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); + expectFinishedInitialDisplaySpan(client.event!); + expectFinishedFullDisplaySpan(client.event!); expectInitialDisplayMeasurementOnSpan(client.event!); expectFullDisplayMeasurementOnSpan(client.event!); - expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(fullDisplayEndTimestampMs / 1_000); + expect(getInitialDisplaySpanJSON(client.event!.spans!)!.timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); + expect(getFullDisplaySpanJSON(client.event!.spans!)!.timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); }); test('should not log a warning if native component exists and not in new architecture', async () => { - (isTurboModuleEnabled as jest.Mock).mockReturnValue(false); TestRenderer.create(); @@ -390,7 +300,6 @@ describe('TimeToDisplay', () => { }); test('should log a warning if in new architecture', async () => { - (isTurboModuleEnabled as jest.Mock).mockReturnValue(true); TestRenderer.create(); await jest.runOnlyPendingTimersAsync(); // Flush setTimeout. @@ -400,62 +309,65 @@ describe('TimeToDisplay', () => { }); }); -function getInitialDisplaySpan(span?: Span) { - return getSpanDescendants(span!)?.find(s => spanToJSON(s).op === 'ui.load.initial_display'); +function getInitialDisplaySpanJSON(spans: SpanJSON[]) { + return spans.find(s => s.op === 'ui.load.initial_display'); } -function getFullDisplaySpan(span?: Span) { - return getSpanDescendants(span!)?.find(s => spanToJSON(s).op === 'ui.load.full_display'); +function getFullDisplaySpanJSON(spans: SpanJSON[]) { + return spans.find(s => s.op === 'ui.load.full_display'); } -function expectFinishedInitialDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) { - expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({ +function expectFinishedInitialDisplaySpan(event: Event) { + expect(getInitialDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.initial_display", [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Time To Initial Display', op: 'ui.load.initial_display', - parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, - start_timestamp: expect.any(Number), + parent_span_id: event.contexts.trace.span_id, + start_timestamp: event.start_timestamp, status: 'ok', timestamp: expect.any(Number), })); } -function expectFinishedFullDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) { - expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({ +function expectFinishedFullDisplaySpan(event: Event) { + expect(getFullDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.full_display", [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Time To Full Display', op: 'ui.load.full_display', - parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, - start_timestamp: expect.any(Number), + parent_span_id: event.contexts.trace.span_id, + start_timestamp: event.start_timestamp, status: 'ok', timestamp: expect.any(Number), })); } -function expectDeadlineExceededFullDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) { - expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({ +function expectDeadlineExceededFullDisplaySpan(event: Event) { + expect(getFullDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.full_display", [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Time To Full Display', op: 'ui.load.full_display', - parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, - start_timestamp: expect.any(Number), + parent_span_id: event.contexts.trace.span_id, + start_timestamp: event.start_timestamp, status: 'deadline_exceeded', timestamp: expect.any(Number), })); } -function expectNoTimeToDisplaySpans(span?: Span) { - expect(getSpanDescendants(span!).map(spanToJSON)).toEqual(expect.not.arrayContaining([ +function expectNoTimeToDisplaySpans(event: Event) { + expect(event.spans).toEqual(expect.not.arrayContaining([ expect.objectContaining>({ op: 'ui.load.initial_display' }), expect.objectContaining>({ op: 'ui.load.full_display' }), ])); diff --git a/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts b/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts index 157a18a03f..6ce19fce89 100644 --- a/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts +++ b/samples/react-native/e2e/captureSpaceflightNewsScreenTransaction.test.ts @@ -4,6 +4,7 @@ import { createSentryServer, containingTransactionWithName, takeSecond, + containingTransaction, } from './utils/mockedSentryServer'; import { getItemOfTypeFrom } from './utils/event'; @@ -13,13 +14,14 @@ describe('Capture Spaceflight News Screen Transaction', () => { let sentryServer = createSentryServer(); sentryServer.start(); - let envelopes: Envelope[] = []; + let newsEnvelopes: Envelope[] = []; + let allTransactionEnvelopes: Envelope[] = []; - const getFirstTransactionEnvelopeItem = () => - getItemOfTypeFrom(envelopes[0], 'transaction'); + const getFirstNewsEventItem = () => + getItemOfTypeFrom(newsEnvelopes[0], 'transaction'); - const getSecondTransactionEnvelopeItem = () => - getItemOfTypeFrom(envelopes[1], 'transaction'); + const getSecondNewsEventItem = () => + getItemOfTypeFrom(newsEnvelopes[1], 'transaction'); beforeAll(async () => { const containingNewsScreen = containingTransactionWithName( @@ -33,7 +35,10 @@ describe('Capture Spaceflight News Screen Transaction', () => { await waitForSpaceflightNewsTx; - envelopes = sentryServer.getAllEnvelopes(containingNewsScreen); + newsEnvelopes = sentryServer.getAllEnvelopes(containingNewsScreen); + allTransactionEnvelopes = sentryServer.getAllEnvelopes( + containingTransaction, + ); }); afterAll(async () => { @@ -41,22 +46,20 @@ describe('Capture Spaceflight News Screen Transaction', () => { }); it('first received new screen transaction was created before the second visit', async () => { - const first = getFirstTransactionEnvelopeItem(); - const second = getSecondTransactionEnvelopeItem(); + const first = getFirstNewsEventItem(); + const second = getSecondNewsEventItem(); expect(first?.[1].timestamp).toBeDefined(); expect(second?.[1].timestamp).toBeDefined(); expect(first![1].timestamp!).toBeLessThan(second![1].timestamp!); }); - it('contains time to display measurements on the first visit', async () => { - expectToContainTimeToDisplayMeasurements(getFirstTransactionEnvelopeItem()); - }); - - it('contains time to display measurements on the second visit', async () => { - expectToContainTimeToDisplayMeasurements( - getSecondTransactionEnvelopeItem(), - ); + it('all transaction envelopes have time to display measurements', async () => { + allTransactionEnvelopes.forEach(envelope => { + expectToContainTimeToDisplayMeasurements( + getItemOfTypeFrom(envelope, 'transaction'), + ); + }); }); function expectToContainTimeToDisplayMeasurements( @@ -79,7 +82,7 @@ describe('Capture Spaceflight News Screen Transaction', () => { } it('contains at least one xhr breadcrumb of request to the news endpoint', async () => { - const item = getFirstTransactionEnvelopeItem(); + const item = getFirstNewsEventItem(); expect(item?.[1]).toEqual( expect.objectContaining({ diff --git a/samples/react-native/e2e/utils/mockedSentryServer.ts b/samples/react-native/e2e/utils/mockedSentryServer.ts index d5fd3de84f..afd32a51c6 100644 --- a/samples/react-native/e2e/utils/mockedSentryServer.ts +++ b/samples/react-native/e2e/utils/mockedSentryServer.ts @@ -125,6 +125,10 @@ export function containingEventWithMessage(message: string) { ); } +export function containingTransaction(envelope: Envelope) { + return envelope[1].some(item => itemHeaderIsType(item[0], 'transaction')); +} + export function containingTransactionWithName(name: string) { return (envelope: Envelope) => envelope[1].some( diff --git a/samples/react-native/src/Screens/ErrorsScreen.tsx b/samples/react-native/src/Screens/ErrorsScreen.tsx index 97e04a972d..c1e3209246 100644 --- a/samples/react-native/src/Screens/ErrorsScreen.tsx +++ b/samples/react-native/src/Screens/ErrorsScreen.tsx @@ -19,6 +19,7 @@ import { UserFeedbackModal } from '../components/UserFeedbackModal'; import { FallbackRender } from '@sentry/react'; import NativeSampleModule from '../../tm/NativeSampleModule'; import NativePlatformSampleModule from '../../tm/NativePlatformSampleModule'; +import { TimeToFullDisplay } from '../utils'; const { AssetsModule, CppModule, CrashModule } = NativeModules; @@ -46,6 +47,7 @@ const ErrorsScreen = (_props: Props) => { <> +