If you've ever tried to get meaningful trace data out of a production Android app, you've probably run into the same ceiling I have. android.os.Trace — and the Jetpack 1.x wrapper around it — gives you named sections that show up in Perfetto, but that's roughly where the story ends. No metadata. No way to tag a trace slice with the actual arguments that were passed to the method you're profiling. No coroutine context propagation, so the moment your code crosses a suspend boundary, the trace lies to you about what was actually happening. And none of it works on the JVM without an Android device attached.
Tracing 2.0.0-beta01, released July 15, 2026, is a full rewrite that fixes all of that. It's not an incremental update to the same mental model — it's a new in-process tracing engine built on Perfetto's own binary format, with an API designed from scratch around the way Kotlin and coroutines actually work.
What Was Actually Wrong with Tracing 1.x
The 1.x library is really just a thin wrapper around the platform's android.os.Trace API. You call Trace.beginSection("loadFeed"), do some work, call Trace.endSection(), and a slice named "loadFeed" appears in your Perfetto trace. That's legitimately useful — it's what I used to instrument the startup path in Nodat when we were chasing that 60% cold start reduction — but it has fundamental limits baked into the platform API itself.
The first limit: no metadata. You can name a section, full stop. You can't annotate it with the query parameters that determined how long it ran, the cache hit/miss status that branched the execution path, or the item count that determined the allocation pattern you're seeing. When you're diagnosing a regression that only manifests under specific runtime conditions, "loadFeed" tells you almost nothing useful without the context that makes one invocation take 8ms and another take 180ms.
The second limit: coroutines are invisible to it. android.os.Trace is fundamentally thread-local. When a coroutine suspends and resumes on a different thread, the trace section doesn't follow it. You end up with a Perfetto trace that shows your coroutine work fragmented across multiple threads with no indication that these disconnected slices belong to the same logical operation. Debugging Samachar's feed preloading pipeline with 1.x tracing was an exercise in reconstruction — piecing together what the coroutine dispatcher was actually doing from trace artifacts that didn't have the context they needed.
The third limit: it only works on Android. If you run business logic on the JVM — in unit tests, in backend services sharing code via KMP, in Gradle plugins — you get nothing. You can't instrument the same code path in both environments without maintaining two separate tracing setups.
Tracing 2.0 is designed to eliminate all three of these limits at once.
The New API: TraceDriver, Tracer, TraceSink
The entry point has changed completely. Instead of static methods on a Trace object, you now work with a TraceDriver that owns a Tracer and a TraceSink. The driver is the control point — you create it once, hold it for the lifetime of the component you're instrumenting, and use it to emit trace events.
// Set up a TraceSink that writes Perfetto proto to a directory
val sink = TraceSink(
sequenceId = 1,
directory = context.getExternalFilesDir("traces")!!
)
// Create the driver — this is what you hold onto
val driver = TraceDriver(sink = sink, isEnabled = BuildConfig.ENABLE_TRACING)
// Use it — the driver is AutoCloseable
driver.use {
driver.tracer.trace(category = "Feed", name = "loadFeed") {
// ... your work here ...
}
}
The Tracer is the API surface you'll interact with most. It gives you three event types: slice events (a named time range with optional metadata), instant events (a point-in-time marker), and counter tracks (a numeric metric over time). Each maps to a first-class Perfetto concept, which means your traces open correctly in both Android Studio's CPU profiler and the standalone Perfetto UI with no post-processing.
Metadata: The Part That Actually Changes How You Debug
The reason metadata matters isn't just completeness — it changes the class of question you can ask when diagnosing a performance problem. With 1.x, you can ask "this slice is slow, which one?" With 2.0, you can ask "this slice is slow when called with these specific arguments, from this specific call site, after this specific state transition."
driver.tracer.trace(
category = "Database",
name = "queryMessages"
) {
// Attach metadata to this specific invocation
addMetadata("conversationId", conversationId)
addMetadata("limit", limit.toString())
addMetadata("offsetPage", page.toString())
// ... run the actual query ...
messageDao.getMessages(conversationId, limit, page * limit)
}
When this slice appears in your Perfetto trace, you can hover over it in the UI and see exactly which conversation, with what page size, at what offset. That's the difference between "my database query is slow sometimes" and "my database query is slow when fetching page 3+ of conversations with more than 500 messages, which tells me I'm missing an index on the composite key."
Instant events work similarly. They're useful for marking discrete state transitions — a cache miss, a network retry, a config fetch completing — that you want to correlate with the slices around them without wrapping them in a begin/end pair:
driver.tracer.instant(
category = "Network",
name = "cacheMiss",
metadata = mapOf("endpoint" to endpoint, "cacheAge" to cacheAge.toString())
)
Coroutine Tracing: The Part That Was Missing Entirely
This is the change I'm most interested in for production use. The new traceCoroutine() API propagates trace context across suspend/resume boundaries, which means a coroutine's trace slice stays logically connected even when the work migrates across threads inside the dispatcher.
// In your ViewModel or use case
suspend fun loadUserFeed(userId: String) {
driver.tracer.traceCoroutine(
category = "Feed",
name = "loadUserFeed"
) {
// Context propagates through suspend calls automatically
val posts = withContext(Dispatchers.IO) {
postRepository.fetchLatest(userId) // suspends, resumes elsewhere
}
val enriched = withContext(Dispatchers.Default) {
enrichPosts(posts) // suspends, resumes elsewhere
}
_feedState.value = FeedState.Success(enriched)
}
}
Without traceCoroutine(), the trace would show three disconnected slices on three different threads, with no indication they're part of the same logical operation. With it, Perfetto receives the context to stitch them together into a single flow — you can see the full wall-clock time for the operation, which thread pool it touched, and where the suspend/resume boundaries actually fell.
Manual propagation tokens: if you're bridging between coroutines and callback-based code (old network libraries, platform callbacks, third-party SDKs that don't understand coroutines), tokenForManualPropagation() gives you a token you can carry across the boundary and reinstall on the other side. It's the escape hatch for the mixed codebase reality most production apps live in.
Counter Tracks: Real-Time Metrics Without a Separate System
Counter tracks are for numeric metrics that change over time — not performance sections, but measurements you want to correlate with sections. Memory pressure, active network connections, queue depth, cache size:
// Define a counter track once — keep the reference
val activeConnections = driver.tracer.counterTrack(
category = "Network",
name = "activeConnections"
)
// Update it as connections open and close
fun onConnectionOpened() {
connectionCount++
activeConnections.set(connectionCount.toLong())
}
fun onConnectionClosed() {
connectionCount--
activeConnections.set(connectionCount.toLong())
}
In Perfetto, counter tracks render as a line graph in a separate lane alongside your slice tracks. When you spot a section that's running slow, you can immediately see whether it correlates with a spike in connection count or a memory pressure event, without needing to instrument a second system or correlate logs after the fact. Counter track APIs in 2.0 are allocation-free — they don't allocate on the hot path when you call set() — which makes them safe to use in rendering paths or tight loops where you'd normally avoid any allocation.
Testing With PerfettoCapture and PerfettoTraceRule
Both PerfettoCapture and PerfettoTraceRule have been updated to support capturing in-process traces in tests. This is significant because it means you can write automated performance regression tests that assert on specific trace events — not just on timing boundaries, but on the presence and metadata of specific operations:
@get:Rule
val traceRule = PerfettoTraceRule()
@Test
fun feedLoad_emitsExpectedTraceEvents() {
val trace = traceRule.runWithTrace {
runBlocking { viewModel.loadUserFeed("user123") }
}
// Assert that specific trace events were emitted
val feedSlices = trace.slices.filter { it.name == "loadUserFeed" }
assertThat(feedSlices).hasSize(1)
assertThat(feedSlices[0].metadata["userId"]).isEqualTo("user123")
}
This pairs naturally with Macrobenchmark for instrumented tests and works on the JVM (without a device) for unit test coverage of instrumented code paths. The JVM support means you can run trace-verified tests in CI without needing an emulator or physical device in the loop.
Migration from Tracing 1.x
Version 1.3.0 — the final 1.x release — continues to work and will not be removed. If you have existing tracing code that uses Trace.beginSection() / Trace.endSection(), it keeps functioning. The 1.x and 2.0 libraries coexist in the same build, so you can migrate incrementally:
// 1.x pattern — still works, not going anywhere
Trace.beginSection("loadFeed")
try {
loadFeedImpl()
} finally {
Trace.endSection()
}
// 2.0 equivalent — structured, metadata-capable, coroutine-aware
driver.tracer.trace(category = "Feed", name = "loadFeed") {
addMetadata("source", source.name)
loadFeedImpl()
}
The migration priority I'd recommend: start with the code paths where you've had to do the most guesswork when diagnosing regressions. For me that would be the async data loading pipelines — anywhere a coroutine chain crosses multiple dispatchers and the current trace gives you a fragmented picture. Convert those first to get coroutine propagation, add metadata to the sections that have the most variance in their timing, and add counter tracks for any metrics you're currently reading from logs.
The Tracing 2.0 output is standard Perfetto proto binary, not a proprietary format. That means traces you capture from your app open directly in the Perfetto UI at ui.perfetto.dev alongside system-level traces from the Android profiler — you get both your app's instrumented events and the kernel scheduler data in a single view, with no extra tooling to install.
What This Means for Production Performance Work
The practical impact depends on how much performance instrumentation you currently have. If your answer is "very little because the existing tools weren't good enough to justify the overhead," Tracing 2.0 is a meaningful unlock. The metadata and coroutine propagation address the two reasons most teams end up relying on logs instead of traces for async performance debugging — logs carry context across threads, and logs let you record arguments. Now a trace can do both.
If you already have extensive android.os.Trace instrumentation, the biggest wins come from converting the sections that are hardest to debug. A section that's consistently 5ms is fine with a name. A section that's 5ms on Monday and 450ms on Tuesday is the one that needs metadata and coroutine propagation to actually understand.
The JVM support is relevant if you share business logic between Android and other Kotlin targets. DataStore 1.3.0 added system tracing via DataStoreFactory.createWithTracing() — once Tracing 2.0 stabilizes, that pattern will work identically in JVM test environments. The convergence of Kotlin Multiplatform and structured observability tooling is something worth building toward deliberately, and Tracing 2.0 is the library-level piece that makes it coherent.
It's beta, which means the API surface can still change before stable. The core concepts — TraceDriver, Tracer, TraceSink, the three event types — feel stable enough that I'd start building with them now on internal tooling and instrumentation code. I'd hold off on making the TraceDriver a core architectural dependency in production until we see the stable release, but that shouldn't stop you from replacing new Trace.beginSection() calls with 2.0 APIs in feature work shipping today.
No comments yet. Be the first to leave one!