Skip to content

Commit 539c9bd

Browse files
feat(appStart): Trace app start on activity restart (#4641)
1 parent b677956 commit 539c9bd

File tree

20 files changed

+648
-136
lines changed

20 files changed

+648
-136
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
- Add thread information to spans ([#4579](https://github.com/getsentry/sentry-react-native/pull/4579))
1414
- Exposed `getDataFromUri` as a public API to retrieve data from a URI ([#4638](https://github.com/getsentry/sentry-react-native/pull/4638))
15+
- Improve Warm App Start reporting on Android ([#4641](https://github.com/getsentry/sentry-react-native/pull/4641))
1516

1617
### Fixes
1718

packages/core/RNSentryAndroidTester/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ dependencies {
4646
testImplementation 'junit:junit:4.13.2'
4747
testImplementation 'org.mockito:mockito-core:5.10.0'
4848
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1'
49+
testImplementation 'org.robolectric:robolectric:4.14.1'
4950
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
5051
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
5152
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package io.sentry.react
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.JavaOnlyMap
5+
import com.facebook.react.bridge.Promise
6+
import com.facebook.react.bridge.WritableMap
7+
import io.sentry.ILogger
8+
import io.sentry.SentryLevel
9+
import io.sentry.android.core.performance.AppStartMetrics
10+
import org.junit.After
11+
import org.junit.Assert.assertEquals
12+
import org.junit.Before
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
import org.mockito.ArgumentCaptor
16+
import org.mockito.Captor
17+
import org.mockito.MockedStatic
18+
import org.mockito.Mockito.any
19+
import org.mockito.Mockito.mock
20+
import org.mockito.Mockito.mockStatic
21+
import org.mockito.Mockito.verify
22+
import org.mockito.MockitoAnnotations
23+
import org.mockito.kotlin.clearInvocations
24+
import org.mockito.kotlin.eq
25+
import org.mockito.kotlin.whenever
26+
import org.robolectric.RobolectricTestRunner
27+
28+
@RunWith(RobolectricTestRunner::class)
29+
class RNSentryAppStartTest {
30+
private lateinit var module: RNSentryModuleImpl
31+
private lateinit var promise: Promise
32+
private lateinit var logger: ILogger
33+
private lateinit var metrics: AppStartMetrics
34+
private lateinit var metricsDataBag: Map<String, Any>
35+
36+
private var argumentsMock: MockedStatic<Arguments>? = null
37+
38+
@Captor
39+
private lateinit var writableMapCaptor: ArgumentCaptor<WritableMap>
40+
41+
@Before
42+
fun setUp() {
43+
MockitoAnnotations.openMocks(this)
44+
45+
promise = mock(Promise::class.java)
46+
logger = mock(ILogger::class.java)
47+
48+
metrics = AppStartMetrics()
49+
metrics.appStartTimeSpan.start()
50+
metrics.appStartTimeSpan.stop()
51+
metricsDataBag = mapOf()
52+
53+
RNSentryModuleImpl.lastStartTimestampMs = -1
54+
55+
module = Utils.createRNSentryModuleWithMockedContext()
56+
57+
// Mock the Arguments class
58+
argumentsMock = mockStatic(Arguments::class.java)
59+
whenever(Arguments.createMap()).thenReturn(JavaOnlyMap())
60+
}
61+
62+
@After
63+
fun tearDown() {
64+
argumentsMock?.close()
65+
}
66+
67+
@Test
68+
fun `fetchNativeAppStart resolves promise with null when app is not launched in the foreground`() {
69+
val metrics = AppStartMetrics()
70+
metrics.isAppLaunchedInForeground = false
71+
72+
val metricsDataBag = mapOf<String, Any>()
73+
74+
module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
75+
76+
verifyWarnOnceWith(
77+
logger,
78+
"Invalid app start data: app not launched in foreground.",
79+
)
80+
81+
verify(promise).resolve(null)
82+
}
83+
84+
@Test
85+
fun `fetchNativeAppStart resolves promise with app start data when app is launched in the foreground`() {
86+
metrics.isAppLaunchedInForeground = true
87+
88+
module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
89+
90+
verifyDebugOnceWith(logger, "App Start data reported to the RN layer for the first time.")
91+
92+
val capturedMap = getWritableMapFromPromiseResolve(promise)
93+
assertEquals(false, capturedMap.getBoolean("has_fetched"))
94+
}
95+
96+
@Test
97+
fun `fetchNativeAppStart marks data as fetched when retried multiple times`() {
98+
metrics.isAppLaunchedInForeground = true
99+
100+
module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
101+
102+
// Clear invocations from the first call
103+
clearInvocations(promise)
104+
clearInvocations(logger)
105+
module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
106+
107+
verifyDebugOnceWith(logger, "App Start data already fetched from native before.")
108+
109+
val capturedMap = getWritableMapFromPromiseResolve(promise)
110+
assertEquals(true, capturedMap.getBoolean("has_fetched"))
111+
}
112+
113+
@Test
114+
fun `fetchNativeAppStart returns updated app start data as not fetched before`() {
115+
metrics.isAppLaunchedInForeground = true
116+
117+
module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
118+
119+
// Clear invocations from the first call
120+
clearInvocations(promise)
121+
clearInvocations(logger)
122+
123+
metrics.onAppStartSpansSent()
124+
metrics.appStartTimeSpan.setStartUnixTimeMs(1741691014000)
125+
metrics.appStartTimeSpan.stop()
126+
module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger)
127+
128+
verifyDebugOnceWith(logger, "App Start data updated, reporting to the RN layer again.")
129+
130+
val capturedMap = getWritableMapFromPromiseResolve(promise)
131+
assertEquals(false, capturedMap.getBoolean("has_fetched"))
132+
}
133+
134+
private fun getWritableMapFromPromiseResolve(promise: Promise): WritableMap {
135+
verify(promise).resolve(any(WritableMap::class.java))
136+
verify(promise).resolve(writableMapCaptor.capture())
137+
return writableMapCaptor.value
138+
}
139+
140+
private fun verifyWarnOnceWith(
141+
logger: ILogger,
142+
value: String,
143+
) {
144+
verify(
145+
logger,
146+
org.mockito.kotlin.times(1),
147+
).log(eq(SentryLevel.WARNING), eq(value))
148+
}
149+
150+
private fun verifyDebugOnceWith(
151+
logger: ILogger,
152+
value: String,
153+
) {
154+
verify(
155+
logger,
156+
org.mockito.kotlin.times(1),
157+
).log(eq(SentryLevel.DEBUG), eq(value))
158+
}
159+
}

packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt

Lines changed: 1 addition & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
11
package io.sentry.react
22

3-
import android.content.pm.PackageInfo
4-
import android.content.pm.PackageManager
5-
import com.facebook.react.bridge.Arguments
63
import com.facebook.react.bridge.JavaOnlyMap
7-
import com.facebook.react.bridge.Promise
8-
import com.facebook.react.bridge.ReactApplicationContext
9-
import com.facebook.react.bridge.WritableMap
104
import com.facebook.react.common.JavascriptException
115
import io.sentry.Breadcrumb
126
import io.sentry.ILogger
13-
import io.sentry.SentryLevel
147
import io.sentry.android.core.SentryAndroidOptions
15-
import org.junit.After
168
import org.junit.Assert.assertEquals
179
import org.junit.Assert.assertFalse
1810
import org.junit.Assert.assertNull
@@ -21,87 +13,18 @@ import org.junit.Before
2113
import org.junit.Test
2214
import org.junit.runner.RunWith
2315
import org.junit.runners.JUnit4
24-
import org.mockito.ArgumentCaptor
25-
import org.mockito.Captor
26-
import org.mockito.MockedStatic
27-
import org.mockito.Mockito.any
28-
import org.mockito.Mockito.anyInt
29-
import org.mockito.Mockito.anyString
3016
import org.mockito.Mockito.mock
31-
import org.mockito.Mockito.mockStatic
32-
import org.mockito.Mockito.verify
33-
import org.mockito.MockitoAnnotations
34-
import org.mockito.kotlin.whenever
3517

3618
@RunWith(JUnit4::class)
3719
class RNSentryModuleImplTest {
3820
private lateinit var module: RNSentryModuleImpl
39-
private lateinit var promise: Promise
4021
private lateinit var logger: ILogger
41-
private var argumentsMock: MockedStatic<Arguments>? = null
42-
43-
@Captor
44-
private lateinit var writableMapCaptor: ArgumentCaptor<WritableMap>
4522

4623
@Before
4724
fun setUp() {
48-
MockitoAnnotations.openMocks(this)
49-
val reactContext = mock(ReactApplicationContext::class.java)
50-
promise = mock(Promise::class.java)
5125
logger = mock(ILogger::class.java)
52-
val packageManager = mock(PackageManager::class.java)
53-
val packageInfo = mock(PackageInfo::class.java)
54-
55-
whenever(reactContext.packageManager).thenReturn(packageManager)
56-
whenever(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo)
57-
58-
module = RNSentryModuleImpl(reactContext)
59-
60-
// Mock the Arguments class
61-
argumentsMock = mockStatic(Arguments::class.java)
62-
val writableMap = mock(WritableMap::class.java)
63-
whenever(Arguments.createMap()).thenReturn(writableMap)
64-
}
65-
66-
@After
67-
fun tearDown() {
68-
argumentsMock?.close()
69-
}
70-
71-
@Test
72-
fun `fetchNativeAppStart resolves promise with null when app is not launched in the foreground`() {
73-
// Mock the app start measurement
74-
val appStartMeasurement = mapOf<String, Any>()
75-
76-
// Call the method
77-
module.fetchNativeAppStart(promise, appStartMeasurement, logger, false)
78-
79-
// Verify a warning log is emitted
80-
verify(logger, org.mockito.kotlin.times(1)).log(
81-
SentryLevel.WARNING,
82-
"Invalid app start data: app not launched in foreground.",
83-
)
84-
85-
// Verify the promise is resolved with null
86-
verify(promise).resolve(null)
87-
}
88-
89-
@Test
90-
fun `fetchNativeAppStart resolves promise with app start data when app is launched in the foreground`() {
91-
// Mock the app start measurement
92-
val appStartMeasurement = mapOf<String, Any>()
93-
94-
// Call the method
95-
module.fetchNativeAppStart(promise, appStartMeasurement, logger, true)
96-
97-
// Verify no logs are emitted
98-
verify(logger, org.mockito.kotlin.times(0)).log(any(), any())
9926

100-
// Verify the promise is resolved with the expected data
101-
verify(promise).resolve(any(WritableMap::class.java))
102-
verify(promise).resolve(writableMapCaptor.capture())
103-
val capturedMap = writableMapCaptor.value
104-
assertEquals(false, capturedMap.getBoolean("has_fetched"))
27+
module = Utils.createRNSentryModuleWithMockedContext()
10528
}
10629

10730
@Test
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package io.sentry.react
2+
3+
import android.content.pm.PackageInfo
4+
import android.content.pm.PackageManager
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import org.mockito.ArgumentMatchers.anyInt
7+
import org.mockito.ArgumentMatchers.anyString
8+
import org.mockito.Mockito.mock
9+
import org.mockito.kotlin.whenever
10+
11+
class Utils {
12+
companion object {
13+
fun createRNSentryModuleWithMockedContext(): RNSentryModuleImpl {
14+
val packageManager = mock(PackageManager::class.java)
15+
val packageInfo = mock(PackageInfo::class.java)
16+
17+
val reactContext = mock(ReactApplicationContext::class.java)
18+
whenever(reactContext.packageManager).thenReturn(packageManager)
19+
whenever(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo)
20+
21+
RNSentryModuleImpl.lastStartTimestampMs = -1
22+
23+
return RNSentryModuleImpl(reactContext)
24+
}
25+
}
26+
}

packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import android.content.res.AssetManager;
1313
import android.net.Uri;
1414
import android.util.SparseIntArray;
15+
import androidx.annotation.VisibleForTesting;
1516
import androidx.core.app.FrameMetricsAggregator;
1617
import androidx.fragment.app.FragmentActivity;
1718
import androidx.fragment.app.FragmentManager;
@@ -105,7 +106,7 @@ public class RNSentryModuleImpl {
105106
private FrameMetricsAggregator frameMetricsAggregator = null;
106107
private boolean androidXAvailable;
107108

108-
private static boolean hasFetchedAppStart;
109+
@VisibleForTesting static long lastStartTimestampMs = -1;
109110

110111
// 700ms to constitute frozen frames.
111112
private static final int FROZEN_FRAME_THRESHOLD = 700;
@@ -432,31 +433,44 @@ public void fetchNativeRelease(Promise promise) {
432433

433434
public void fetchNativeAppStart(Promise promise) {
434435
fetchNativeAppStart(
435-
promise,
436-
InternalSentrySdk.getAppStartMeasurement(),
437-
logger,
438-
AppStartMetrics.getInstance().isAppLaunchedInForeground());
436+
promise, AppStartMetrics.getInstance(), InternalSentrySdk.getAppStartMeasurement(), logger);
439437
}
440438

441439
protected void fetchNativeAppStart(
442440
Promise promise,
443-
final Map<String, Object> appStartMeasurement,
444-
ILogger logger,
445-
boolean isAppLaunchedInForeground) {
446-
if (!isAppLaunchedInForeground) {
441+
final AppStartMetrics metrics,
442+
final Map<String, Object> metricsDataBag,
443+
ILogger logger) {
444+
if (!metrics.isAppLaunchedInForeground()) {
447445
logger.log(SentryLevel.WARNING, "Invalid app start data: app not launched in foreground.");
448446
promise.resolve(null);
449447
return;
450448
}
451449

452450
WritableMap mutableMeasurement =
453-
(WritableMap) RNSentryMapConverter.convertToWritable(appStartMeasurement);
454-
mutableMeasurement.putBoolean("has_fetched", hasFetchedAppStart);
451+
(WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag);
455452

456-
// This is always set to true, as we would only allow an app start fetch to only
457-
// happen once in the case of a JS bundle reload, we do not want it to be
458-
// instrumented again.
459-
hasFetchedAppStart = true;
453+
long currentStartTimestampMs = metrics.getAppStartTimeSpan().getStartTimestampMs();
454+
boolean hasFetched =
455+
lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs;
456+
mutableMeasurement.putBoolean("has_fetched", hasFetched);
457+
458+
if (lastStartTimestampMs < 0) {
459+
logger.log(SentryLevel.DEBUG, "App Start data reported to the RN layer for the first time.");
460+
} else if (hasFetched) {
461+
logger.log(SentryLevel.DEBUG, "App Start data already fetched from native before.");
462+
} else {
463+
logger.log(SentryLevel.DEBUG, "App Start data updated, reporting to the RN layer again.");
464+
}
465+
466+
// When activity is destroyed but the application process is kept alive
467+
// the next activity creation is considered warm start.
468+
// The app start metrics will be updated by the the Android SDK.
469+
// To let the RN JS layer know these are new start data we compare the start timestamps.
470+
lastStartTimestampMs = currentStartTimestampMs;
471+
472+
// Clears start metrics, making them ready for recording warm app start
473+
metrics.onAppStartSpansSent();
460474

461475
promise.resolve(mutableMeasurement);
462476
}

0 commit comments

Comments
 (0)