Skip to content

Commit 29e8ebe

Browse files
authored
Refactor app start and refresh rate to use FFI and JNI (#3288)
* Update * Update * Update * Update * Update * Update * Configure diagnostic log * Update log messages * Update * Update * Update * Update * Update * Update * Update * Update * Update * Update * Fix test * Update * Update * Update * Add automatedTestMode option * Update * Fix web tests * Update * Update * Add close * Review * Review * Update * Update * Update * Update * Update * Update * Update * Update * Update * Update * Update * Fix tests * Update * Update * Update * Update * Update * Update * Update * Update * Update * Update * Update * Update * Release original in JNI to avoid memory leak * Update
1 parent f934ddf commit 29e8ebe

File tree

14 files changed

+512
-273
lines changed

14 files changed

+512
-273
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Enhancements
1111

1212
- Refactor `AndroidReplayRecorder` to use the new worker isolate api [#3296](https://github.com/getsentry/sentry-dart/pull/3296/)
13+
- Refactor fetching app start and display refresh rate to use FFI and JNI [#3288](https://github.com/getsentry/sentry-dart/pull/3288/)
1314
- Offload `captureEnvelope` to background isolate for Cocoa and Android [#3232](https://github.com/getsentry/sentry-dart/pull/3232)
1415
- Add `sentry.replay_id` to flutter logs ([#3257](https://github.com/getsentry/sentry-dart/pull/3257))
1516

packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt

Lines changed: 130 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,6 @@ class SentryFlutterPlugin :
4141
ActivityAware {
4242
private lateinit var channel: MethodChannel
4343
private lateinit var context: Context
44-
private lateinit var sentryFlutter: SentryFlutter
45-
46-
private var activity: WeakReference<Activity>? = null
47-
private var pluginRegistrationTime: Long? = null
4844

4945
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
5046
pluginRegistrationTime = System.currentTimeMillis()
@@ -65,7 +61,6 @@ class SentryFlutterPlugin :
6561
when (call.method) {
6662
"initNativeSdk" -> initNativeSdk(call, result)
6763
"closeNativeSdk" -> closeNativeSdk(result)
68-
"fetchNativeAppStart" -> fetchNativeAppStart(result)
6964
"setContexts" -> setContexts(call.argument("key"), call.argument("value"), result)
7065
"removeContexts" -> removeContexts(call.argument("key"), result)
7166
"setUser" -> setUser(call.argument("user"), result)
@@ -75,7 +70,6 @@ class SentryFlutterPlugin :
7570
"removeExtra" -> removeExtra(call.argument("key"), result)
7671
"setTag" -> setTag(call.argument("key"), call.argument("value"), result)
7772
"removeTag" -> removeTag(call.argument("key"), result)
78-
"displayRefreshRate" -> displayRefreshRate(result)
7973
"nativeCrash" -> crash()
8074
"setReplayConfig" -> setReplayConfig(call, result)
8175
"captureReplay" -> captureReplay(result)
@@ -151,106 +145,6 @@ class SentryFlutterPlugin :
151145
}
152146
}
153147

154-
private fun fetchNativeAppStart(result: Result) {
155-
if (!sentryFlutter.autoPerformanceTracingEnabled) {
156-
result.success(null)
157-
return
158-
}
159-
160-
val appStartMetrics = AppStartMetrics.getInstance()
161-
162-
if (!appStartMetrics.isAppLaunchedInForeground ||
163-
appStartMetrics.appStartTimeSpan.durationMs > APP_START_MAX_DURATION_MS
164-
) {
165-
Log.w(
166-
"Sentry",
167-
"Invalid app start data: app not launched in foreground or app start took too long (>60s)",
168-
)
169-
result.success(null)
170-
return
171-
}
172-
173-
val appStartTimeSpan = appStartMetrics.appStartTimeSpan
174-
val appStartTime = appStartTimeSpan.startTimestamp
175-
val isColdStart = appStartMetrics.appStartType == AppStartMetrics.AppStartType.COLD
176-
177-
if (appStartTime == null) {
178-
Log.w("Sentry", "App start won't be sent due to missing appStartTime")
179-
result.success(null)
180-
} else {
181-
val appStartTimeMillis = DateUtils.nanosToMillis(appStartTime.nanoTimestamp().toDouble())
182-
val item =
183-
184-
mutableMapOf<String, Any?>(
185-
"pluginRegistrationTime" to pluginRegistrationTime,
186-
"appStartTime" to appStartTimeMillis,
187-
"isColdStart" to isColdStart,
188-
)
189-
190-
val androidNativeSpans = mutableMapOf<String, Any?>()
191-
192-
val processInitSpan =
193-
TimeSpan().apply {
194-
description = "Process Initialization"
195-
setStartUnixTimeMs(appStartTimeSpan.startTimestampMs)
196-
setStartedAt(appStartTimeSpan.startUptimeMs)
197-
setStoppedAt(appStartMetrics.classLoadedUptimeMs)
198-
}
199-
processInitSpan.addToMap(androidNativeSpans)
200-
201-
val applicationOnCreateSpan = appStartMetrics.applicationOnCreateTimeSpan
202-
applicationOnCreateSpan.addToMap(androidNativeSpans)
203-
204-
val contentProviderSpans = appStartMetrics.contentProviderOnCreateTimeSpans
205-
contentProviderSpans.forEach { span ->
206-
span.addToMap(androidNativeSpans)
207-
}
208-
209-
appStartMetrics.activityLifecycleTimeSpans.forEach { span ->
210-
span.onCreate.addToMap(androidNativeSpans)
211-
span.onStart.addToMap(androidNativeSpans)
212-
}
213-
214-
item["nativeSpanTimes"] = androidNativeSpans
215-
216-
result.success(item)
217-
}
218-
}
219-
220-
private fun displayRefreshRate(result: Result) {
221-
var refreshRate: Int? = null
222-
223-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
224-
val display = activity?.get()?.display
225-
if (display != null) {
226-
refreshRate = display.refreshRate.toInt()
227-
}
228-
} else {
229-
val display =
230-
activity
231-
?.get()
232-
?.window
233-
?.windowManager
234-
?.defaultDisplay
235-
if (display != null) {
236-
refreshRate = display.refreshRate.toInt()
237-
}
238-
}
239-
240-
result.success(refreshRate)
241-
}
242-
243-
private fun TimeSpan.addToMap(map: MutableMap<String, Any?>) {
244-
if (startTimestamp == null) return
245-
246-
description?.let { description ->
247-
map[description] =
248-
mapOf<String, Any?>(
249-
"startTimestampMsSinceEpoch" to startTimestampMs,
250-
"stopTimestampMsSinceEpoch" to projectedStopTimestampMs,
251-
)
252-
}
253-
}
254148
private fun setContexts(
255149
key: String?,
256150
value: Any?,
@@ -374,23 +268,137 @@ class SentryFlutterPlugin :
374268
result.success("")
375269
}
376270

271+
@Suppress("TooManyFunctions")
377272
companion object {
378273
@SuppressLint("StaticFieldLeak")
379274
private var replay: ReplayIntegration? = null
380275

381276
@SuppressLint("StaticFieldLeak")
382277
private var applicationContext: Context? = null
383278

279+
@SuppressLint("StaticFieldLeak")
280+
private var activity: WeakReference<Activity>? = null
281+
282+
private var pluginRegistrationTime: Long? = null
283+
284+
private lateinit var sentryFlutter: SentryFlutter
285+
384286
private const val NATIVE_CRASH_WAIT_TIME = 500L
385287

386288
@Suppress("unused") // Used by native/jni bindings
387289
@JvmStatic
388290
fun privateSentryGetReplayIntegration(): ReplayIntegration? = replay
389291

292+
@Suppress("unused") // Used by native/jni bindings
293+
@JvmStatic
294+
fun getDisplayRefreshRate(): Int? {
295+
var refreshRate: Int? = null
296+
297+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
298+
val display = activity?.get()?.display
299+
if (display != null) {
300+
refreshRate = display.refreshRate.toInt()
301+
}
302+
} else {
303+
val display =
304+
activity
305+
?.get()
306+
?.window
307+
?.windowManager
308+
?.defaultDisplay
309+
if (display != null) {
310+
refreshRate = display.refreshRate.toInt()
311+
}
312+
}
313+
314+
return refreshRate
315+
}
316+
317+
@Suppress("unused", "ReturnCount", "TooGenericExceptionCaught") // Used by native/jni bindings
318+
@JvmStatic
319+
fun fetchNativeAppStartAsBytes(): ByteArray? {
320+
if (!sentryFlutter.autoPerformanceTracingEnabled) {
321+
return null
322+
}
323+
324+
val appStartMetrics = AppStartMetrics.getInstance()
325+
326+
if (!appStartMetrics.isAppLaunchedInForeground ||
327+
appStartMetrics.appStartTimeSpan.durationMs > APP_START_MAX_DURATION_MS
328+
) {
329+
Log.w(
330+
"Sentry",
331+
"Invalid app start data: app not launched in foreground or app start took too long (>60s)",
332+
)
333+
return null
334+
}
335+
336+
val appStartTimeSpan = appStartMetrics.appStartTimeSpan
337+
val appStartTime = appStartTimeSpan.startTimestamp
338+
val isColdStart = appStartMetrics.appStartType == AppStartMetrics.AppStartType.COLD
339+
340+
if (appStartTime == null) {
341+
Log.w("Sentry", "App start won't be sent due to missing appStartTime")
342+
return null
343+
}
344+
345+
val appStartTimeMillis = DateUtils.nanosToMillis(appStartTime.nanoTimestamp().toDouble())
346+
val item =
347+
mutableMapOf<String, Any?>(
348+
"pluginRegistrationTime" to pluginRegistrationTime,
349+
"appStartTime" to appStartTimeMillis,
350+
"isColdStart" to isColdStart,
351+
)
352+
353+
val androidNativeSpans = mutableMapOf<String, Any?>()
354+
355+
val processInitSpan =
356+
TimeSpan().apply {
357+
description = "Process Initialization"
358+
setStartUnixTimeMs(appStartTimeSpan.startTimestampMs)
359+
setStartedAt(appStartTimeSpan.startUptimeMs)
360+
setStoppedAt(appStartMetrics.classLoadedUptimeMs)
361+
}
362+
addTimeSpanToMap(processInitSpan, androidNativeSpans)
363+
364+
val applicationOnCreateSpan = appStartMetrics.applicationOnCreateTimeSpan
365+
addTimeSpanToMap(applicationOnCreateSpan, androidNativeSpans)
366+
367+
val contentProviderSpans = appStartMetrics.contentProviderOnCreateTimeSpans
368+
contentProviderSpans.forEach { span ->
369+
addTimeSpanToMap(span, androidNativeSpans)
370+
}
371+
372+
appStartMetrics.activityLifecycleTimeSpans.forEach { span ->
373+
addTimeSpanToMap(span.onCreate, androidNativeSpans)
374+
addTimeSpanToMap(span.onStart, androidNativeSpans)
375+
}
376+
377+
item["nativeSpanTimes"] = androidNativeSpans
378+
379+
val json = JSONObject(item).toString()
380+
return json.toByteArray(Charsets.UTF_8)
381+
}
382+
383+
private fun addTimeSpanToMap(
384+
span: TimeSpan,
385+
map: MutableMap<String, Any?>,
386+
) {
387+
if (span.startTimestamp == null) return
388+
389+
span.description?.let { description ->
390+
map[description] =
391+
mapOf<String, Any?>(
392+
"startTimestampMsSinceEpoch" to span.startTimestampMs,
393+
"stopTimestampMsSinceEpoch" to span.projectedStopTimestampMs,
394+
)
395+
}
396+
}
397+
390398
@JvmStatic
391399
fun getApplicationContext(): Context? = applicationContext
392400

393-
@Suppress("unused") // Used by native/jni bindings
401+
@Suppress("unused", "ReturnCount", "TooGenericExceptionCaught") // Used by native/jni bindings
394402
@JvmStatic
395403
fun loadContextsAsBytes(): ByteArray? {
396404
val options = ScopesAdapter.getInstance().options
@@ -405,11 +413,16 @@ class SentryFlutterPlugin :
405413
options,
406414
currentScope,
407415
)
408-
val json = JSONObject(serializedScope).toString()
409-
return json.toByteArray(Charsets.UTF_8)
416+
try {
417+
val json = JSONObject(serializedScope).toString()
418+
return json.toByteArray(Charsets.UTF_8)
419+
} catch (e: Exception) {
420+
Log.e("Sentry", "Failed to serialize scope", e)
421+
return null
422+
}
410423
}
411424

412-
@Suppress("unused") // Used by native/jni bindings
425+
@Suppress("unused", "TooGenericExceptionCaught") // Used by native/jni bindings
413426
@JvmStatic
414427
fun loadDebugImagesAsBytes(addresses: Set<String>): ByteArray? {
415428
val options = ScopesAdapter.getInstance().options as SentryAndroidOptions
@@ -428,8 +441,13 @@ class SentryFlutterPlugin :
428441
.serialize()
429442
}
430443

431-
val json = JSONArray(debugImages).toString()
432-
return json.toByteArray(Charsets.UTF_8)
444+
try {
445+
val json = JSONArray(debugImages).toString()
446+
return json.toByteArray(Charsets.UTF_8)
447+
} catch (e: Exception) {
448+
Log.e("Sentry", "Failed to serialize debug images", e)
449+
return null
450+
}
433451
}
434452

435453
private fun List<DebugImage>?.serialize() = this?.map { it.serialize() }

packages/flutter/example/integration_test/integration_test.dart

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,60 @@ void main() {
599599
expect(debugImageByStacktrace.first.imageAddr, expectedImage.imageAddr);
600600
});
601601

602+
testWidgets('fetchNativeAppStart returns app start data', (tester) async {
603+
await restoreFlutterOnErrorAfter(() async {
604+
await setupSentryAndApp(tester);
605+
});
606+
607+
if (Platform.isAndroid || Platform.isIOS) {
608+
// fetchNativeAppStart should return data on mobile platforms
609+
final appStart = await SentryFlutter.native?.fetchNativeAppStart();
610+
611+
expect(appStart, isNotNull, reason: 'App start data should be available');
612+
613+
if (appStart != null) {
614+
expect(appStart.appStartTime, greaterThan(0),
615+
reason: 'App start time should be positive');
616+
expect(appStart.pluginRegistrationTime, greaterThan(0),
617+
reason: 'Plugin registration time should be positive');
618+
expect(appStart.isColdStart, isA<bool>(),
619+
reason: 'isColdStart should be a boolean');
620+
expect(appStart.nativeSpanTimes, isA<Map>(),
621+
reason: 'Native span times should be a map');
622+
}
623+
} else {
624+
// On other platforms, it should return null
625+
final appStart = await SentryFlutter.native?.fetchNativeAppStart();
626+
expect(appStart, isNull,
627+
reason: 'App start should be null on non-mobile platforms');
628+
}
629+
});
630+
631+
testWidgets('displayRefreshRate returns valid refresh rate', (tester) async {
632+
await restoreFlutterOnErrorAfter(() async {
633+
await setupSentryAndApp(tester);
634+
});
635+
636+
if (Platform.isAndroid || Platform.isIOS) {
637+
final refreshRate = await SentryFlutter.native?.displayRefreshRate();
638+
639+
// Refresh rate should be available on mobile platforms
640+
expect(refreshRate, isNotNull,
641+
reason: 'Display refresh rate should be available');
642+
643+
if (refreshRate != null) {
644+
expect(refreshRate, greaterThan(0),
645+
reason: 'Refresh rate should be positive');
646+
expect(refreshRate, lessThanOrEqualTo(1000),
647+
reason: 'Refresh rate should be reasonable (<=1000Hz)');
648+
}
649+
} else {
650+
final refreshRate = await SentryFlutter.native?.displayRefreshRate();
651+
expect(refreshRate, isNull,
652+
reason: 'Refresh rate should be null or positive on other platforms');
653+
}
654+
});
655+
602656
group('e2e', () {
603657
var output = find.byKey(const Key('output'));
604658
late Fixture fixture;

0 commit comments

Comments
 (0)