DEV Community

Cover image for Evidence Before the Alarm: Trigger-Based Profiling on Android with ProfilingManager
Android Expert
Android Expert

Posted on Originally published at Medium

Evidence Before the Alarm: Trigger-Based Profiling on Android with ProfilingManager

Picture a courier-tracking app called Parcelo. Support keeps forwarding the same complaint: “the parcel list froze for a few seconds, then Android offered to close the app.” Play Console shows the ANR cluster. Your stack traces show the main thread parked in a lock. Nobody can say who held the lock, and nobody can reproduce it on a Pixel at a desk.

This is the class of problem where classic profiling fails: you can’t start a recorder before an event you can’t predict. ProfilingManager trigger-based capture exists for exactly this gap. The platform keeps a short-lived trace running in the background at times of its own choosing, and when a registered event fires, it hands your app the recent past as a file.

This post walks through the mechanics, a production-minded Kotlin setup, and the ways it quietly fails in the field.

1. An alarm is not a cause

An ANR is reported when the system notices the main thread has been unresponsive. By then, the expensive call may have returned. If your code reacts to the signal by starting a profile, you record a healthy app recovering from an incident that is already over.

ui-thread-timeline

The same logic applies to other events you can’t schedule: low-memory kills, odd exits, or a slow first frame on a device you’ve never held. The useful evidence is before the trigger. Trigger-based capture is the platform’s answer: it records speculatively, then keeps the recording only when something interesting happens.

2. What the platform is actually doing

Two design decisions shape everything you’ll observe.

Traces are sampled, not continuous. To protect battery and performance, the system doesn’t trace around the clock. It starts background traces at randomized moments, bounded by a minimum and maximum duration. Randomness spreads coverage so that triggers aren’t systematically missed.

Traces are a ring buffer. Snapshots have a system-defined maximum size. When the buffer fills, new data overwrites the oldest. What you receive is therefore the most recent slice before the trigger, not necessarily the full span since the trace began.

ring-buffer-window

Implications worth writing on a whiteboard:

  • A trigger that fires while no background trace is running produces nothing.
  • A profile is a sample of incidents, not a complete log of them.
  • A long incident may show up with its beginning chopped off.

3. The path from event to callback

Registration is cheap. The interesting part is everything the system checks before a file appears.

trigger-lifecycle

Three gates must all be open: a background trace must be live, your app must have global rate-limiter quota left, and any cooldown you attached to that specific trigger must have elapsed. If the process isn’t running when a profile is saved, delivery waits until the app next starts and registers its listener. That last detail drives one of the most important rules below: register early.

4. A Kotlin setup you can actually ship

The official snippet is a single function, which is perfect for learning. In a real app I want three things: registration in one place, results exposed as a stream, and zero heavy work on the callback.

4.1 Model outcomes explicitly

import android.os.ProfilingResult

sealed interface CaptureOutcome {
    data class Saved(val path: String) : CaptureOutcome
    data class Failed(val code: Int, val message: String?) : CaptureOutcome
}

private fun ProfilingResult.toOutcome(): CaptureOutcome {
    val path = resultFilePath
    return if (errorCode == ProfilingResult.ERROR_NONE && path != null) {
        CaptureOutcome.Saved(path)
    } else {
        CaptureOutcome.Failed(errorCode, errorMessage)
    }
}
Enter fullscreen mode Exit fullscreen mode

The platform contract is that a success code comes with a file path, so a null path alongside ERROR_NONE should not happen. I still treat it as a failure on purpose: Kotlin sees resultFilePath as a platform type, and a defensive check costs one line while an NPE in an uploader costs a night of paging.

4.2 One class owns registration

import android.content.Context
import android.os.Build
import android.os.ProfilingManager
import android.os.ProfilingTrigger
import androidx.annotation.RequiresApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow

@RequiresApi(Build.VERSION_CODES.BAKLAVA)
class TriggerProfiler(context: Context) {

    private val manager = context.getSystemService(ProfilingManager::class.java)

    // UNLIMITED so a burst of results delivered at startup is never dropped.
    // Growth is bounded in practice by the system rate limiter, but if your
    // collector stalls, entries (just small path strings) sit in memory.
    private val channel = Channel<CaptureOutcome>(Channel.UNLIMITED)
    val outcomes: Flow<CaptureOutcome> = channel.receiveAsFlow()

    private val plan: List<ProfilingTrigger> = listOf(
        ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_APP_FULLY_DRAWN)
            .setRateLimitingPeriodHours(1)
            .build(),
        ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_ANR)
            .setRateLimitingPeriodHours(6)
            .build(),
    )

    fun start() {
        manager.registerForAllProfilingResults(Dispatchers.IO.asExecutor()) { result ->
            channel.trySend(result.toOutcome())
        }
        manager.addProfilingTriggers(plan)
    }
}
Enter fullscreen mode Exit fullscreen mode

Notes on the choices:

  • The listener is registered before the triggers are added, so there is never a window where an event can fire with nobody listening.
  • Dispatchers.IO.asExecutor() keeps the callback off the main thread without inventing a new thread pool. The callback only enqueues; it never reads or uploads the file.
  • TRIGGER_TYPE_APP_FULLY_DRAWN comes straight from the official sample. TRIGGER_TYPE_ANR is the second trigger I use for ANR capture. The full list of trigger types lives in the ProfilingTrigger reference, so pick the ones you need from there.
  • The listener stays registered for the life of the process, so keep TriggerProfiler a process-wide singleton (as the Application below does) rather than creating it per screen.

4.3 Register at the very start of the process

import android.app.Application
import android.os.Build
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch

class ParceloApp : Application() {

    private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    override fun onCreate() {
        super.onCreate()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
            val profiler = TriggerProfiler(this).also { it.start() }
            appScope.launch {
                profiler.outcomes.collect { outcome ->
                    when (outcome) {
                        is CaptureOutcome.Saved -> ProfileUploads.enqueue(this@ParceloApp, outcome.path)
                        is CaptureOutcome.Failed -> Log.w("Profiling", "code=${outcome.code} ${outcome.message}")
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Registering in Application.onCreate matters twice over. Profiles saved while your process was dead are delivered only after a listener appears, so late registration means late evidence. And because the channel is buffered, you can start collecting a moment later without losing anything.

4.4 Make the startup trigger fire

TRIGGER_TYPE_APP_FULLY_DRAWN is driven by your app reporting that it's usable. If nothing calls reportFullyDrawn(), the trigger never fires and your dashboards stay empty. In Compose, tie it to real readiness rather than the first frame:

@Composable
fun ParcelListScreen(state: ParcelListState) {
    ReportDrawnWhen { state is ParcelListState.Loaded }
    // ... list UI
}
Enter fullscreen mode Exit fullscreen mode

“Fully drawn” should mean the parcel list is on screen with data, not that a spinner appeared. The trace you receive is only as meaningful as that definition.

5. Hand the file to WorkManager

Profile files can be large and the network is unreliable. Treat the callback as a doorbell and let a worker do the lifting.

import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.CoroutineWorker
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CancellationException

object ProfileUploads {
    private const val KEY_PATH = "path"

    fun enqueue(context: Context, path: String) {
        val request = OneTimeWorkRequestBuilder<ProfileUploadWorker>()
            .setInputData(workDataOf(KEY_PATH to path))
            .setConstraints(
                Constraints.Builder()
                    .setRequiredNetworkType(NetworkType.UNMETERED)
                    .setRequiresBatteryNotLow(true)
                    .build()
            )
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
            .build()

        // Unique per file: a redelivered callback must not upload twice.
        WorkManager.getInstance(context).enqueueUniqueWork(
            "profile-upload-${File(path).name}", ExistingWorkPolicy.KEEP, request,
        )
    }

    internal fun pathOf(data: Data): String? = data.getString(KEY_PATH)
}

class ProfileUploadWorker(
    appContext: Context,
    params: WorkerParameters,
) : CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        val file = ProfileUploads.pathOf(inputData)?.let(::File)
        if (file == null || !file.exists()) return Result.failure()

        return try {
            ParceloBackend.uploadTrace(file, metadata = currentBuildMetadata())
            file.delete()
            Result.success()
        } catch (e: CancellationException) {
            throw e // never swallow cancellation
        } catch (e: IOException) {
            if (runAttemptCount < 3) Result.retry() else Result.failure()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details separate this from a tutorial: the CancellationException rethrow (wrapping suspend calls in runCatching quietly breaks cooperative cancellation), and the early Result.failure() for a missing file, because retrying a vanished file is wasted battery.

6. Rate limiting is two layers, not one

Your app has a global quota for profile collection. A noisy trigger can burn through it and starve the rare, valuable ones. The fix is a second, per-trigger cooldown set with setRateLimitingPeriodHours.

rate-limit-layers

Rules of thumb I’d apply:

  • Frequent, low-value events (startup completes on every cold launch) get a cooldown of hours.
  • Rare, high-value events (ANRs) get a short cooldown or none, but still deserve one if a bad release could cause a burst.
  • Review the platform’s rate-limiter explanation before choosing numbers; the cooldown only adds restriction on top of it and can never raise your quota.

7. Debugging locally without waiting for luck

Because background traces start at random times, a trigger you fire while testing will usually find no trace running and produce nothing. That is correct behavior, and also maddening. Force a continuous background trace for your package:

PKG=com.parcelo.app
adb shell device_config put profiling_testing system_triggered_profiling.testing_package_name $PKG
Enter fullscreen mode Exit fullscreen mode

Now every trigger can collect, as long as the rate limiter allows it. The platform’s debug-mode page covers further switches, including how to relax the rate limiter during development.

To fetch a result, don’t guess the location. Log resultFilePath and pull that. Triggered files use a name shaped like profile_trigger_<type code>_<timestamp>.<type name>:

adb pull /data/user/0/com.parcelo.app/files/profiling/profile_trigger_1_2026-10-19-21-40-12.perfetto-trace
Enter fullscreen mode Exit fullscreen mode

Pulling from the app’s private directory needs root. The official page suggests the Perfetto temp directory (/data/misc/perfetto-traces/profiling/) as the alternative. Access to /data subtrees varies by build, so on a locked-down retail device it may still be refused. In that case use a debuggable build or a userdebug device, or copy the file out of a debuggable app with adb exec-out run-as <package> cat files/profiling/<file>. Open the result in the Perfetto UI, or follow the platform's retrieve-and-analyze guide for bulk workflows.

8. Failure modes you will meet in the field

Nothing here crashes. Everything here just makes your data worse.

failure-modes

A few deserve more than a table row:

Survivorship bias. If profiles only exist when a background trace happened to be live and quota remained, your corpus under-represents some devices and some moments. Don’t compute incident rates from profile counts. Use profiles to explain incidents you already measured elsewhere (Play vitals, your own crash and ANR counters).

Silent registration drift. If three feature modules each call addProfilingTriggers with slightly different cooldowns, you'll be reasoning about an accidental union of configurations. Keep one owner, one list, and a unit test that asserts the list.

The truncated beginning. A ring buffer favors the moments just before the trigger. For an incident whose cause sits further back, add your own Trace sections around the suspect code so the interesting spans exist in whatever slice survives.

Privacy. A system trace can describe what your process and the device were doing. Decide with your privacy and legal owners what you may collect, who can read uploaded traces, how long you keep them, and whether users are told. Do it before the first upload, not after.

9. From file to fix

The capture is just the first of five hand-offs, and each can lose the evidence on its own.

capture-to-insight-pipeline

Tag every upload with the app version, device model, OS build and trigger type. The trace explains one incident; the metadata lets you ask whether it clusters on one release or one chipset.

10. Rollout checklist

  • [ ] Registered in Application.onCreate, from exactly one class
  • [ ] Listener registered before triggers are added
  • [ ] Callback only enqueues; no file I/O or network on that thread
  • [ ] errorCode checked and resultFilePath read, never hard-coded
  • [ ] A cooldown on every high-frequency trigger
  • [ ] reportFullyDrawn() (or ReportDrawnWhen) tied to real readiness
  • [ ] Uploads deduplicated, constrained, and capped on retries
  • [ ] Retention and consent decisions documented
  • [ ] Dashboards measure incidents independently of profile counts

🔚 Closing thought

Trigger-based profiling doesn’t make incidents reproducible. It makes them inspectable after the fact, which for ANRs and other unpredictable events is often the only option you have. Treat each profile as a well-timed sample, wire the plumbing carefully, and be honest in your analysis about what the sampling can’t tell you.

Sources

🧵 Stop Treating Coroutines Like Magic Keywords

If “just add launch and hope” describes how your team handles concurrency, I’d recommend “Kotlin Coroutines Under the Hood” — a 25-chapter deep dive that goes past the syntax and down to the bytecode: the Continuation-Passing Style transform, the compiler-generated state machine, and exactly why a suspended coroutine costs a few dozen bytes while a blocked thread costs a megabyte.

It walks through structured concurrency and Job hierarchies, dispatcher internals (Default vs IO vs Unconfined), cooperative cancellation and exception aggregation, Flow’s cold-stream semantics and the full operator vocabulary, StateFlow/SharedFlow’s hot-broadcast model, Channels and fan-in/fan-out patterns, Mutex/Semaphore/actors for shared state, and a complete Android case study retrofitting a legacy callback-driven app onto coroutines and Flow — including a 100,000-coroutines-vs-100,000-threads benchmark lab you can run yourself.


Originally published on Medium.

Top comments (0)