Monitoring Android Vitals with the Play Developer Reporting API

At Aircall, we built an automated reporting tool that fetches our Android Vitals metrics thanks to the Play Developer Reporting API and delivers a daily health dashboard straight to Slack. All of this powered by a simple Gradle task.

Discovering the API

The Google Play Developer Reporting API gives programmatic access to the same quality metrics you see in the Google Play Console. It covers the core Android Vitals: ANR rate, crash rate, slow start rate, and stuck background wakelocks, the signals Google uses to determine whether your app meets the bad behavior thresholds.

The API is organized around metric sets that you query with a timeline specification. Each metric set returns daily, 7-day, and 28-day user-weighted aggregates, giving you the full picture from short-term regressions to long-term trends. The key advantage over scraping the Console UI is that you can automate the entire flow and integrate it into your existing CI/CD pipeline.

You will find the complete API reference in the official documentation.

Build with Play Vitals

Setting up the dependency

We chose to implement this as a Gradle task inside our build-logic module. This keeps the reporting logic close to the project without polluting the app code. First, let’s add the dependency:

# libs.versions.toml
googleApiReporting = "v1beta1-rev20230803-2.0.0"
buildLogic-plugin-google-play-developer-reporting = { module = "com.google.apis:google-api-services-playdeveloperreporting", version.ref = "googleApiReporting" }

Which can then be added to the build.gradle.kts of your build-logic module:

dependencies {
// API to get Google Play store vitals metrics
implementation(libs.buildLogic.plugin.google.play.developer.reporting)
}

Authenticating with a Service Account

To access the API, you need a Google Cloud Service Account with the Play Developer Reporting scope. We pass the credentials as an environment variable to keep secrets out of the repository. The Reporting client initializes the API with these credentials:

class Reporting {
private var reporting: Playdeveloperreporting

init {
val credentials = GoogleCredentials
.fromStream(REPORTING_SERVICE_ACCOUNT.byteInputStream())
.createScoped("https://www.googleapis.com/auth/playdeveloperreporting")

reporting = Playdeveloperreporting.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
GsonFactory.getDefaultInstance(),
HttpCredentialsAdapter(credentials),
).setApplicationName("reporting").build()
}
}

Querying the Vitals data

Each metric set has its own query endpoint. Here’s how we fetched the ANR stats with daily, weekly and monthly aggregates:

fun getAnrStats(): Pair? {
val resName = "$BASE/anrRateMetricSet"
val metrics = listOf(
"anrRate",
"anrRate7dUserWeighted",
"anrRate28dUserWeighted",
"userPerceivedAnrRate",
"userPerceivedAnrRate7dUserWeighted",
"userPerceivedAnrRate28dUserWeighted",
)

val row = reporting.vitals().anrrate().query(
resName,
GooglePlayDeveloperReportingV1beta1QueryAnrRateMetricSetRequest()
.setTimelineSpec(timelineSpec)
.setMetrics(metrics),
).execute().rows[0]

val map = row.metrics.associate {
it.metric to it.decimalValue.value.toFloat() * 100
}

return AnrStats(
anrRate = map["anrRate"] ?: NOT_FOUND,
anrRateWeek = map["anrRate7dUserWeighted"] ?: NOT_FOUND,
anrRateMonth = map["anrRate28dUserWeighted"] ?: NOT_FOUND,
) to UserPerceivedAnrStats(
userPerceivedAnrRate = map["userPerceivedAnrRate"] ?: NOT_FOUND,
userPerceivedAnrRateWeek = map["userPerceivedAnrRate7dUserWeighted"] ?: NOT_FOUND,
userPerceivedAnrRateMonth = map["userPerceivedAnrRate28dUserWeighted"] ?: NOT_FOUND,
)
}

The same pattern applies for crash rate, slow start rate, stuck background wakelocks, and error counts. Each endpoint follows the same query structure, only the metric set name and metric names change. The timeline specification defines the aggregation window. We query daily data anchored to the America/Los_Angeles timezone (as required by the API):

val timelineSpec = GooglePlayDeveloperReportingV1beta1TimelineSpec()
.setAggregationPeriod("DAILY")
.setStartTime(
GoogleTypeDateTime()
.setTimeZone(losAngelesTimeZone)
.setDay(startTimeLosAngeles.dayOfMonth)
.setMonth(startTimeLosAngeles.monthValue)
.setYear(startTimeLosAngeles.year),
)
.setEndTime(
GoogleTypeDateTime()
.setTimeZone(losAngelesTimeZone)
.setDay(endTimeLosAngeles.dayOfMonth)
.setMonth(endTimeLosAngeles.monthValue)
.setYear(endTimeLosAngeles.year),
)

Modeling the data

We keep the data models simple. Each metric set maps to its own data class. For example, here is the data class for the ANR metrics.

data class AnrStats(
val anrRate: Float,
val anrRateWeek: Float,
val anrRateMonth: Float,
) : Stats()

Adding health thresholds

Raw numbers are useful, but what the team really needs is a quick visual status. We defined thresholds aligned with Google’s bad behavior levels. This gives us a traffic-light system: green when we’re safely below thresholds, yellow when approaching, and red when we’ve crossed Google’s bad behavior limits.

const val ANR_RATE_LOWER_THRESHOLD = 0.25f        // Green below
const val ANR_RATE_UPPER_THRESHOLD = 0.47f // Red above

private fun computeStatusEmoji(
value: Float,
lowerThreshold: Float,
upperThreshold: Float,
): String {
return when {
value >= upperThreshold -> ":red_circle:"
value < lowerThreshold -> ":large_green_circle:"
else -> ":large_yellow_circle:"
}
}

Delivering the report to Slack

Now we’re fully ready to build our Slack report to send it in our dedicated Slack channel. By using the Java Slack API, we’re now able to have a Gradle task that can be trigger from the our CI to deliver the report every morning.

fun sendSlackMessage(statsBlocks: ReportStatsBlocks, errorCounts: ErrorCounts?) {
val slack = Slack.getInstance()

slack.methods(SLACK_TOKEN).chatPostMessage { chatRequest ->
chatRequest.channel(SLACK_REPORTS_CHANNEL)
.username("Report bot").iconEmoji(":android:")
.blocks {
header {
text("Report for $twoDaysAgo")
}
divider()
section {
fields {
markdownText("*Period*")
markdownText("*Day* *Week* *Month*")
}
}
section {
fields {
statsBlocks.anrStatsBlock?.let { buildLine(it.title, it.body) }
statsBlocks.crashStatsBlock?.let { buildLine(it.title, it.body) }
markdownText("*Crash count*")
plainText("${errorCounts?.crashCount} for ${errorCounts?.crashDistinctUsers} users")
}
}
// ... slow start, stuck wakelocks
}
}
}

Now we have everything to register our new reporting Gradle task in our TaskRegistry so we can run it our CI environment

// Main Reporting task
open class ReportingTask : DefaultTask() {

private val reporting = Reporting()
private val statsMapper = StatsMapper()

@TaskAction
fun report() {
val anrStats = reporting.getAnrStats()
val errorCounts = reporting.getErrorCount()
...

val reportStatsBlocks = ReportStatsBlocks(
anrStatsBlock = anrStats?.first?.let { statsMapper.map(it) },
...,
)

sendSlackMessage(statsBlocks = reportStatsBlocks, errorCounts = errorCounts)
}
}

// Task registration
private fun Project.registerReporting() {
tasks.register(
"reporting",
ReportingTask::class.java,
) { task ->
task.group = "notification"
task.description = """
Send Slack report with vitals
""".trimIndent()
}
}

Here is the final report we’re getting on our Slack channel every morning:

Every morning, our #android-reports Slack channel receives a clean health dashboard with all core Android Vitals. Each metric shows its daily value, 7-day trend, and 28-day aggregate with color-coded status and trend arrows so the team can assess the app health at a glance without opening the Play Console.

The entire tool lives in our build-logic module as a single Gradle task with ~300 lines of code. No extra infrastructure, no external service to maintain, just a CI job that runs ./gradlew reporting once a day. This approach gives us a general overview of the app health but also give visibility to the stakeholders on how the app behaves in production.

Do not hesitate to ping me on LinkedIn if you have any question 🤓


Monitoring Android Vitals with the Play Developer Reporting API was originally published in Google Developer Experts on Medium, where people are continuing the conversation by highlighting and responding to this story.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Popular AI gateway startup LiteLLM ditches controversial startup Delve

Next Post

How to implement Drag and Drop in Kotlin Multiplatform

Related Posts