Skip to content

Commit 5043164

Browse files
runningcodeclaude
andauthored
test(android): Add Jetpack Macrobenchmark for SDK init performance (JAVA-604) (#5680)
Add sentry-uitest-android-macrobenchmark, a cold-start Macrobenchmark targeting sentry-samples-android. It reports timeToInitialDisplay from framework trace events, requiring no trace markers in the SDK or app. Per-iteration perfetto traces are saved for deeper analysis. CompilationMode.Full pins ART AOT; iterations are capped at 12 because back-to-back cold starts thermally throttle after ~14 iterations. To A/B an SDK change, interleave per-variant rounds so thermal drift does not systematically penalize the second variant (see README). Verified on a Galaxy A55 (Android 16): TTID median 514.5ms over 12 iterations. Requires androidx.benchmark 1.4.1; 1.3.4 fails to confirm activity launches on API 36. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent aab952b commit 5043164

7 files changed

Lines changed: 157 additions & 0 deletions

File tree

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ tomcat-catalina-jakarta = { module = "org.apache.tomcat:tomcat-catalina", versio
234234
tomcat-embed-jasper-jakarta = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "11.0.22" }
235235

236236
# test libraries
237+
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version = "1.4.1" }
237238
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version = "1.9.5" }
238239
androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" }
239240
androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidxTestCore" }
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# sentry-uitest-android-macrobenchmark
2+
3+
Jetpack Macrobenchmark for cold-start of `sentry-samples-android`, used to evaluate SDK-init
4+
performance changes on a real device in a **stable, reproducible** way. Not run in CI.
5+
6+
## What it measures
7+
8+
`SentryStartupBenchmark` runs a cold start and reports **`timeToInitialDisplay`**
9+
(`StartupTimingMetric`) per iteration — the whole app cold start, taken from framework trace
10+
events. No trace markers are required in the SDK or the app.
11+
12+
The flip side of marker-free measurement: an SDK change has to be large enough (roughly tens of
13+
milliseconds) to show above cold-start noise. Sub-millisecond changes are not resolvable with
14+
`timeToInitialDisplay` alone; for those, capture a perfetto trace and inspect the relevant slices
15+
directly (each iteration's trace is saved under
16+
`build/outputs/connected_android_test_additional_output/`).
17+
18+
`CompilationMode.Full()` pins ART AOT so dexopt state can't drift between runs. `StartupMode.COLD`
19+
does the correct force-stop sequencing (it does **not** `pm clear`, so app data/permissions are
20+
kept). Iterations are capped at 12 because back-to-back cold starts thermally throttle an
21+
unlocked-clock device after ~14 iterations, inflating the tail of longer runs.
22+
23+
## Running
24+
25+
Connect a device, then:
26+
27+
```bash
28+
./gradlew :sentry-android-integration-tests:sentry-uitest-android-macrobenchmark:connectedBenchmarkAndroidTest
29+
```
30+
31+
Results print to the console and are written to
32+
`build/outputs/connected_android_test_additional_output/.../*-benchmarkData.json`.
33+
34+
### Device hygiene (do this for trustworthy numbers)
35+
36+
- **Wake and unlock the device first** — the launch check fails with "Unable to confirm activity
37+
launch completion" on a dozing/locked screen
38+
(`adb shell input keyevent KEYCODE_WAKEUP && adb shell wm dismiss-keyguard`).
39+
- **Charge above 25%** — Macrobenchmark refuses to run below that.
40+
- **Lock CPU clocks** if the device is rooted: this is the single biggest cure for thermal drift.
41+
- Otherwise: let the device cool between runs, keep it on AC power, enable airplane mode, and turn
42+
animations off (`adb shell settings put global window_animation_scale 0`, plus
43+
`transition_animation_scale` and `animator_duration_scale`).
44+
- Heed Macrobenchmark's warnings about unlocked clocks / low battery — they mean the numbers are
45+
noisy.
46+
47+
## A/B-ing an SDK change
48+
49+
Macrobenchmark measures one build per run, so compare separate runs — but **interleave them**:
50+
running all of variant A followed by all of variant B lets thermal drift systematically penalize
51+
whichever variant runs second. Instead, alternate A/B rounds (build variant A, run, build variant
52+
B, run, repeat 2–3 times), keep each round's `*-benchmarkData.json`, and compare the
53+
`timeToInitialDisplay` values pooled per variant.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
plugins {
2+
id("com.android.test")
3+
alias(libs.plugins.kotlin.android)
4+
}
5+
6+
android {
7+
namespace = "io.sentry.uitest.android.macrobenchmark"
8+
compileSdk = libs.versions.compileSdk.get().toInt()
9+
10+
defaultConfig {
11+
// Macrobenchmark requires API 23+.
12+
minSdk = 24
13+
targetSdk = libs.versions.targetSdk.get().toInt()
14+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
15+
}
16+
17+
buildTypes {
18+
// Pairs with the app's release build via matchingFallbacks. The test APK itself must be
19+
// debuggable (to instrument) and signed (to install); only the target app needs to be
20+
// genuinely release-like.
21+
create("benchmark") {
22+
isDebuggable = true
23+
signingConfig = signingConfigs.getByName("debug")
24+
matchingFallbacks += listOf("release")
25+
}
26+
}
27+
28+
compileOptions {
29+
sourceCompatibility = JavaVersion.VERSION_11
30+
targetCompatibility = JavaVersion.VERSION_11
31+
}
32+
33+
kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 }
34+
35+
targetProjectPath = ":sentry-samples:sentry-samples-android"
36+
// Run the test in its own process so it measures the target app cold, not itself.
37+
experimentalProperties["android.experimental.self-instrumenting"] = true
38+
}
39+
40+
// Benchmarks only make sense against the release build; drop the debug variant entirely.
41+
androidComponents { beforeVariants(selector().withBuildType("debug")) { it.enable = false } }
42+
43+
dependencies {
44+
implementation(libs.androidx.test.ext.junit)
45+
implementation(libs.androidx.benchmark.macro.junit4)
46+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package io.sentry.uitest.android.macrobenchmark
2+
3+
import androidx.benchmark.macro.CompilationMode
4+
import androidx.benchmark.macro.StartupMode
5+
import androidx.benchmark.macro.StartupTimingMetric
6+
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
7+
import androidx.test.ext.junit.runners.AndroidJUnit4
8+
import org.junit.Rule
9+
import org.junit.Test
10+
import org.junit.runner.RunWith
11+
12+
/**
13+
* Cold-start benchmark for the sentry-samples-android app, used to evaluate SDK-init changes on a
14+
* real device in a stable, repeatable way.
15+
*
16+
* Reports timeToInitialDisplay ([StartupTimingMetric]) per iteration. This measures the whole app
17+
* cold start from framework trace events, with no trace markers in the SDK or the app — which also
18+
* means SDK changes need to be large enough (roughly tens of milliseconds) to show above cold-start
19+
* noise.
20+
*
21+
* [CompilationMode.Full] pins ART AOT compilation so dexopt state does not drift between runs.
22+
* Iterations are capped at 12: on an unthrottled Pixel 3, back-to-back cold starts hit thermal
23+
* throttling after ~14 iterations, which inflates the tail of longer runs. This is NOT a CI test;
24+
* it requires a connected device. To A/B an SDK change, see README.md (build the app twice, once
25+
* per SDK variant, in interleaved rounds).
26+
*/
27+
@RunWith(AndroidJUnit4::class)
28+
class SentryStartupBenchmark {
29+
30+
@get:Rule val benchmarkRule = MacrobenchmarkRule()
31+
32+
@Test
33+
fun startupFullCompilation() =
34+
benchmarkRule.measureRepeated(
35+
packageName = TARGET_PACKAGE,
36+
metrics = listOf(StartupTimingMetric()),
37+
compilationMode = CompilationMode.Full(),
38+
startupMode = StartupMode.COLD,
39+
iterations = 12,
40+
setupBlock = { pressHome() },
41+
) {
42+
startActivityAndWait()
43+
}
44+
45+
private companion object {
46+
const val TARGET_PACKAGE = "io.sentry.samples.android"
47+
}
48+
}

sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
android:theme="@style/AppTheme"
3232
tools:ignore="GoogleAppIndexingWarning, UnusedAttribute">
3333

34+
<!-- Allows Macrobenchmark (and `am profile`) to profile non-debuggable release/benchmark
35+
builds. Harmless in debug; required for the benchmark build type. -->
36+
<profileable
37+
android:shell="true"
38+
tools:targetApi="29" />
39+
3440
<service
3541
android:name=".DummyService"
3642
android:enabled="true"

settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ include(
125125
"sentry-samples:sentry-samples-netflix-dgs",
126126
"sentry-android-integration-tests:sentry-uitest-android-critical",
127127
"sentry-android-integration-tests:sentry-uitest-android-benchmark",
128+
"sentry-android-integration-tests:sentry-uitest-android-macrobenchmark",
128129
"sentry-android-integration-tests:sentry-uitest-android",
129130
"sentry-android-integration-tests:test-app-plain",
130131
"sentry-android-integration-tests:test-app-sentry",

0 commit comments

Comments
 (0)