In-process tracing

The androidx.tracing:tracing:2.0.0-beta01 library is a low-overhead Kotlin API that lets you capture in-process trace events. These events can capture time slices and their context. The library also supports context propagation for Kotlin coroutines.

The library uses the same Perfetto trace packet format that Android developers are familiar with. Also, Tracing 2.0 (unlike the 1.0.0-* APIs) supports the notion of pluggable tracing backends and sinks, so other tracing libraries can customize the output tracing format, and how context propagation works in their implementation.

Dependencies

To start tracing, you need to define the dependencies in your build.gradle.kts.

Kotlin Multiplatform projects

Libraries that only need to emit trace events should depend on the lightweight androidx.tracing:tracing API. Apps that configure the tracing backend should also depend on androidx.tracing:tracing-wire.

kotlin {
  sourceSets {
    commonMain {
      dependencies {
        // API definition
        implementation("androidx.tracing:tracing:2.0.0-beta01")
      }
    }
    androidMain {
      dependencies {
        // Android implementation (includes the Perfetto Sink and automatic initialization)
        implementation("androidx.tracing:tracing-wire:2.0.0-beta01")
      }
    }
    jvmMain {
      dependencies {
        // JVM implementation
        implementation("androidx.tracing:tracing-wire:2.0.0-beta01")
      }
    }
  }
}

Android-only projects

If you're targeting Android only, add the following to your application or library's build.gradle.kts file:

dependencies {
    // For libraries and applications to emit events
    implementation("androidx.tracing:tracing:2.0.0-beta01")

    // For applications to configure the tracing backend
    implementation("androidx.tracing:tracing-wire:2.0.0-beta01")
}

Initialization and discovery

Before you can record trace events, you must initialize the tracing infrastructure. This involves creating an AbstractTraceDriver and registering its Tracer globally.

Android

On Android, if you include the androidx.tracing:tracing-wire dependency, initialization happens automatically on application startup using the androidx.startup library.

By default, this automatic initialization does the following:

  • Creates a TraceDriver with a TraceSink that writes Perfetto trace files to Context.noBackupFilesDir/perfetto_traces/.

  • Registers the resulting Tracer globally.

Customize the TraceDriver instance

If you need to customize the configuration, for example to change where trace files are saved or to use a custom TraceSink, you can provide your own AbstractTraceDriver instance.

To customize the configuration, make your Application class implement AbstractTraceDriver.Factory:

import android.app.Application
import androidx.tracing.AbstractTraceDriver
import androidx.tracing.wire.TraceDriver
import androidx.tracing.wire.TraceSink
import java.io.File

class App : Application(), AbstractTraceDriver.Factory {
    override fun create(): AbstractTraceDriver {
        val sink = TraceSink(
            context = this,
            fileProvider = { File(noBackupFilesDir, "traces") },
        )
        // Return the custom TraceDriver
        // You can also fully customize the instance of Tracer
        return TraceDriver(context = this, sink = sink)
    }
}

The automatic initializer detects that your Application subclass implements Factory and uses your custom driver factory.

JVM

On the JVM, there's no automatic bootstrapping mechanism. The application is responsible for initializing the TraceDriver and registering the Tracer globally during startup, most commonly in your main function.

To register the tracer, call Tracer.setGlobalTracer().

import androidx.tracing.Tracer
import androidx.tracing.DelicateTracingApi
import androidx.tracing.wire.TraceDriver
import androidx.tracing.wire.TraceSink
import java.io.File

fun main() {
    // Create the TraceSink, and the `TraceDriver`
    val outputDirectory = File("/tmp/perfetto")
    val sink = TraceSink(directory = outputDirectory)
    val driver = TraceDriver(sink = sink, isEnabled = true)

    // Register the tracer
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    // Call driver.close() as a result of the process shutdown hook.
    Runtime.getRuntime().addShutdownHook(Thread {
        driver.close()
    })
}

Basic usage

A TraceSink defines how trace packets are serialized. Tracing 2.0.0 comes with an implementation of a Sink that uses the Perfetto trace packet format. A TraceDriver provides a handle to the Tracer and can be used to finalize a trace.

Once the Tracer is initialized, either automatically on Android or manually on the JVM, use the global Tracer.global instance to emit trace events.

You can also use the TraceDriver to disable all trace points in the application, if you choose not to trace at all in some application variants. You can optionally enable trace points for a given category by providing an implementation for isCategoryEnabled when creating an instance of TraceDriver.

val driver = TraceDriver(
    sink = sink,
    isCategoryEnabled = { category ->
        // Only enable trace points in the "com.example" package
        category.startsWith("com.example")
    }
)

Here's a basic example of emitting a trace event using Tracer.global on the JVM, including manual setup:

import androidx.tracing.Tracer
import androidx.tracing.DelicateTracingApi
import androidx.tracing.wire.TraceDriver
import androidx.tracing.wire.TraceSink
import java.io.File

// Category names should also follow the same convention used for package names
// on Android and Java. This makes them easier to identify and filter.
internal const val CATEGORY_MAIN = "com.example"

fun createSink(): TraceSink {
    val outputDirectory = File("/tmp/perfetto")
    if (!outputDirectory.exists()) {
        outputDirectory.mkdirs()
    }
    return TraceSink(directory = outputDirectory)
}

fun createTraceDriver(): TraceDriver {
    return TraceDriver(sink = createSink(), isCategoryEnabled = {true})
}

fun main() {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        Tracer.global.trace(category = CATEGORY_MAIN, name = "basic") {
            // The block of code that needs to be traced.
            Thread.sleep(100L)
        }
    }
}

This generates the following trace.

Screen capture of a basic Perfetto trace

Figure 1. Screen capture of a basic Perfetto trace.

You can see that the correct process and thread tracks are populated and that they produced a single trace section basic, which ran for 100 ms.

Trace sections (or slices) can be nested on the same track to represent overlapping events. Here is an example.

fun main() {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        Tracer.global.trace(
            category = CATEGORY_MAIN,
            name = "processImage",
        ) {
            // Load the data first, then apply the sharpen filter
            sharpen(output = loadImage())
        }
    }
}

internal fun loadImage(): ByteArray {
    return Tracer.global.trace(CATEGORY_MAIN, "loadImage") {
        // Loads an image
        // ...
        // A placeholder
        ByteArray(0)
    }
}

internal fun sharpen(output: ByteArray) {
    // ...
    Tracer.global.trace(CATEGORY_MAIN, "sharpen") {
        // ...
    }
}

This generates the following trace.

Screen capture of a basic Perfetto trace with nested sections

Figure 2. Screen capture of a basic Perfetto trace with nested sections.

You can see that there are overlapping events in the main thread track. It is very clear that processImage calls loadImage and sharpen on the same thread.

Add additional metadata in trace sections

Sometimes, it can be useful to attach additional contextual metadata to a trace slice, to get more details. Some examples of such metadata could include the nav destination that the user is on, or input arguments that might end up determining how long a function takes.

fun main() {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        Tracer.global.trace(
            category = CATEGORY_MAIN,
            name = "basicWithContext",
            // Add additional metadata
            metadataBlock = {
                // Add key value pairs.
                addMetadataEntry("key", "value")
                addMetadataEntry("count", 1L)
            }
        ) {
            Thread.sleep(100L)
        }
    }
}

This produces the following result. Note the Arguments section contains key value pairs added when producing the slice.

Screen capture of a basic Perfetto trace with additional metadata

Figure 3. Screen capture of a basic Perfetto trace with additional metadata.

Context propagation

When using Kotlin coroutines, or other similar frameworks that help with concurrent workloads, Tracing 2.0 supports the notion of context propagation. This is best explained by an example.

suspend fun taskOne() {
    Tracer.global.traceCoroutine(category = CATEGORY_MAIN, "taskOne") {
        delay(timeMillis = 100L)
    }
}

suspend fun taskTwo() {
    Tracer.global.traceCoroutine(category = CATEGORY_MAIN, "taskTwo") {
        delay(timeMillis = 50L)
    }
}

fun main() = runBlocking(context = Dispatchers.Default) {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        Tracer.global.traceCoroutine(category = CATEGORY_MAIN, name = "main") {
              taskOne()
              taskTwo()
            }
        }
        println("All done")
    }
}

This produces the following result.

Screen capture of a Perfetto trace with context propagation

Figure 4. Screen capture of a basic Perfetto trace with context propagation.

Context Propagation makes it a lot simpler to visualize the flow of execution. You can see exactly which tasks were related (connected to others), and exactly when Threads were suspended and resumed.

For example, you can see that the slice main spawned taskOne and taskTwo. After that, both threads were inactive because the coroutines were suspended due to the use of delay.

Manual propagation

Sometimes, when you're mixing concurrent workloads using Kotlin coroutines with instances of Java Executor it might be useful to propagate the context from one to the other. Here is an example:

fun executorTask(
    token: PropagationToken,
    executor: Executor,
    callback: () -> Unit
) {
    executor.execute {
        Tracer.global.trace(
            category = CATEGORY_MAIN,
            name = "executeTask",
            token = token,
        ) {
            // Do something
            Thread.sleep(100)
            callback()
        }
    }
}

fun main() = runBlocking(context = Dispatchers.Default) {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    val executor = Executors.newSingleThreadExecutor()
    driver.use {
        Tracer.global.traceCoroutine(category = CATEGORY_MAIN, name = "main") {
            coroutineScope {
                val deferred = CompletableDeferred<Unit>()
                executorTask(
                    // Obtain the propagation token from the CoroutineContext
                    token = Tracer.global.tokenFromCoroutineContext(),
                    executor = executor,
                    callback = {
                        deferred.complete(Unit)
                    }
                )
                deferred.await()
            }
        }
        executor.shutdownNow()
    }
}

This produces the following result.

Screen capture of a Perfetto trace with manual context propagation

Figure 5. Screen capture of a basic Perfetto trace with manual context propagation.

You can see that execution started in a CoroutineContext, and subsequently switched to a Java Executor, but we were still able to use context propagation.

Combine with system traces

The androidx.tracing library doesn't capture information like CPU scheduling, memory usage, and the application's interaction with the operating system in general. This is because the library provides a way to perform low-overhead in-process tracing.

However, it is extremely trivial to merge system traces with in-process traces and visualize them as a single trace if needed. This is because Perfetto UI supports visualizing multiple trace files from a device on a unified timeline.

To do this, you can start a system tracing session using Perfetto UI by following instructions here.

You can also record in-process trace events using the Tracing 2.0 API, while system tracing is turned on. Once you have both trace files you can use the Open Multiple Trace Files option in Perfetto.

Opening multiple trace files in Perfetto UI

Figure 6. Opening multiple trace files in Perfetto UI.

Advanced workflows

This section describes advanced workflows you can implement with the in-process tracing library.

Correlate slices

Sometimes, it is useful to attribute slices in a trace to a more high level user action or a system event. For example to attribute all the slices that correspond to some background work as part of a notification, you could do something like:

fun main() {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        onEvent(eventId = EVENT_ID)
    }
}

fun onEvent(eventId: Long) {
    Tracer.global.trace(
        category = CATEGORY_MAIN,
        name = "step-1",
        metadataBlock = {
            addCorrelationId(eventId)
        }
    ) {
        Thread.sleep(100L)
    }

    Thread.sleep(20)

    Tracer.global.trace(
        category = CATEGORY_MAIN,
        name = "step-2",
        metadataBlock = {
            addCorrelationId(eventId)
        }
    ) {
        Thread.sleep(180)
    }
}

This produces the following result.

Screen capture of a Perfetto trace with correlated slices

Figure 7. Screen capture of a Perfetto trace with correlated slices.

Add call stack information

Host-side tools, such as compiler plugins and annotation processors, can also choose to embed call stack information into a trace, to make it convenient to locate the file, class, or method responsible for producing a trace section in a trace.

fun main() {
    val driver = createTraceDriver()
    @OptIn(DelicateTracingApi::class)
    Tracer.setGlobalTracer(driver.tracer)

    driver.use {
        Tracer.global.trace(
            category = CATEGORY_MAIN,
            name = "callStackEntry",
            metadataBlock = {
                addCallStackEntry(
                    name = "main",
                    lineNumber = 14,
                    sourceFile = "Basic.kt"
                )
            }
        ) {
            Thread.sleep(100L)
        }
    }
}

This produces the following result.

Screen capture of a Perfetto trace with call stack information

Figure 8. Screen capture of a Perfetto trace with call stack information.