Traçage dans le processus

La bibliothèque androidx.tracing:tracing:2.0.0-beta01 est une API Kotlin à faible surcharge qui vous permet de capturer des événements de trace en cours de processus. Ces événements peuvent capturer des tranches de temps et leur contexte. La bibliothèque est également compatible avec la propagation du contexte pour les coroutines Kotlin.

La bibliothèque utilise le même Perfetto format de paquet de trace que celui que les développeurs Android connaissent bien. De plus, Tracing 2.0 (contrairement aux API 1.0.0-*) est compatible avec la notion de backends de traçage enfichables et de récepteurs, de sorte que d'autres bibliothèques de traçage peuvent personnaliser le format de traçage de sortie et le fonctionnement de la propagation du contexte dans leur implémentation.

Dépendances

Pour commencer le traçage, vous devez définir les dépendances dans votre fichier build.gradle.kts.

Projets multiplateformes Kotlin

Les bibliothèques qui n'ont besoin d'émettre que des événements de trace doivent dépendre de l'API légère androidx.tracing:tracing. Les applications qui configurent le backend de traçage doivent également dépendre de 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")
      }
    }
  }
}

Projets Android uniquement

Si vous ne ciblez qu'Android, ajoutez les éléments suivants au fichier build.gradle.kts de votre application ou de votre bibliothèque :

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")
}

Initialisation et découverte

Avant de pouvoir enregistrer des événements de trace, vous devez initialiser l'infrastructure de traçage. Cela implique de créer un AbstractTraceDriver et d'enregistrer son Tracer de manière globale.

Android

Sur Android, si vous incluez la dépendance androidx.tracing:tracing-wire, l'initialisation se produit automatiquement au démarrage de l'application à l'aide de la bibliothèque androidx.startup.

Par défaut, cette initialisation automatique effectue les opérations suivantes :

  • Crée un TraceDriver avec un TraceSink qui écrit les fichiers de trace Perfetto dans Context.noBackupFilesDir/perfetto_traces/.

  • Enregistre le Tracer résultant de manière globale.

Personnaliser l'instance TraceDriver

Si vous devez personnaliser la configuration, par exemple pour modifier l'emplacement d'enregistrement des fichiers de trace ou utiliser un TraceSink personnalisé, vous pouvez fournir votre propre instance AbstractTraceDriver.

Pour personnaliser la configuration, faites en sorte que votre classe Application implémente 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)
    }
}

L'initialiseur automatique détecte que votre sous-classe Application implémente Factory et utilise votre usine de pilotes personnalisée.

JVM

Sur la JVM, il n'existe aucun mécanisme d'amorçage automatique. L'application est chargée d'initialiser le TraceDriver et d'enregistrer le Tracer de manière globale au démarrage, le plus souvent dans votre fonction main.

Pour enregistrer le traceur, appelez 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()
    })
}

Utilisation de base

Un TraceSink définit la manière dont les paquets de trace sont sérialisés. Tracing 2.0.0 est fourni avec une implémentation d'un récepteur qui utilise le format de paquet de trace Perfetto. Un TraceDriver fournit un handle au Tracer et peut être utilisé pour finaliser une trace.

Une fois le Tracer initialisé, automatiquement sur Android ou manuellement sur la JVM, utilisez l'instance globale Tracer.global pour émettre des événements de trace.

Vous pouvez également utiliser le TraceDriver pour désactiver tous les points de trace dans l'application, si vous choisissez de ne pas effectuer de traçage dans certaines variantes d'application. Vous pouvez éventuellement activer des points de trace pour une category donnée en fournissant une implémentation pour isCategoryEnabled lors de la création d'une instance de TraceDriver.

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

Voici un exemple de base d'émission d'un événement de trace à l'aide de Tracer.global sur la JVM, y compris la configuration manuelle :

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)
        }
    }
}

La trace suivante est générée.

Capture d'écran d'une trace Perfetto de base

Figure 1. Capture d'écran d'une trace Perfetto de base.

Vous pouvez constater que les pistes de processus et de threads correctes sont renseignées et qu'elles ont produit une seule section de trace basic, qui s'est exécutée pendant 100 ms.

Les sections de trace (ou tranches) peuvent être imbriquées sur la même piste pour représenter des événements qui se chevauchent. Voici un exemple :

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") {
        // ...
    }
}

La trace suivante est générée.

Capture d'écran d'une trace Perfetto de base avec des sections imbriquées

Figure 2. Capture d'écran d'une trace Perfetto de base avec des sections imbriquées.

Vous pouvez constater que des événements se chevauchent dans la piste de thread principal. Il est très clair que processImage appelle loadImage et sharpen sur le même thread.

Ajouter des métadonnées supplémentaires dans les sections de trace

Il peut parfois être utile d'associer des métadonnées contextuelles supplémentaires à une tranche de trace pour obtenir plus de détails. Par exemple, ces métadonnées peuvent inclure la nav destination sur laquelle se trouve l'utilisateur ou les input arguments qui peuvent déterminer la durée d'une fonction.

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)
        }
    }
}

Le résultat suivant est généré. Notez que la section Arguments contient des paires clé-valeur ajoutées lors de la production de la slice.

Capture d'écran d'une trace Perfetto de base avec des métadonnées supplémentaires

Figure 3. Capture d'écran d'une trace Perfetto de base avec des métadonnées supplémentaires.

Propagation du contexte

Lorsque vous utilisez des coroutines Kotlin ou d'autres frameworks similaires qui facilitent les charges de travail simultanées, Tracing 2.0 est compatible avec la notion de propagation du contexte. Le mieux est d'expliquer cela à l'aide d'un exemple.

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")
    }
}

Le résultat suivant est généré.

Capture d'écran d'une trace Perfetto avec propagation du contexte

Figure 4. Capture d'écran d'une trace Perfetto de base avec propagation du contexte.

La propagation du contexte facilite grandement la visualisation du flux d'exécution. Vous pouvez voir exactement quelles tâches étaient liées (connectées à d'autres) et à quel moment précis les Threads ont été suspendus et reprises.

Par exemple, vous pouvez voir que la tranche main a généré taskOne et taskTwo. Ensuite, les deux threads étaient inactifs, car les coroutines ont été suspendues en raison de l'utilisation de delay.

Propagation manuelle

Parfois, lorsque vous combinez des charges de travail simultanées à l'aide de coroutines Kotlin avec des instances de Java Executor, il peut être utile de propager le contexte de l'une à l'autre. Voici un exemple :

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()
    }
}

Le résultat suivant est généré.

Capture d'écran d'une trace Perfetto avec propagation manuelle du contexte

Figure 5. Capture d'écran d'une trace Perfetto de base avec propagation manuelle du contexte.

Vous pouvez constater que l'exécution a commencé dans un CoroutineContext, puis est passée à un Executor Java, mais nous avons toujours pu utiliser la propagation du contexte.

Combiner avec des traces système

La bibliothèque androidx.tracing ne capture pas d'informations telles que la planification du processeur, l'utilisation de la mémoire et l'interaction de l'application avec le système d'exploitation en général. En effet, la bibliothèque permet d'effectuer un traçage en cours de processus à faible surcharge.

Toutefois, il est extrêmement simple de fusionner des traces système avec des traces en cours de processus et de les visualiser sous forme de trace unique si nécessaire. En effet, Perfetto UI permet de visualiser plusieurs fichiers de trace d'un appareil sur une chronologie unifiée.

Pour ce faire, vous pouvez démarrer une session de traçage système à l'aide de Perfetto UI en suivant les instructions disponibles ici.

Vous pouvez également enregistrer des événements de trace en cours de processus à l'aide de l'API Tracing 2.0, lorsque le traçage système est activé. Une fois que vous disposez des deux fichiers de trace, vous pouvez utiliser l'option Open Multiple Trace Files (Ouvrir plusieurs fichiers de trace) dans Perfetto.

Ouvrir plusieurs fichiers de trace dans l'interface utilisateur de Perfetto

Figure 6. Ouverture de plusieurs fichiers de trace dans l'interface utilisateur de Perfetto.

Workflows avancés

Cette section décrit les workflows avancés que vous pouvez implémenter avec la bibliothèque de traçage en cours de processus.

Corréler des tranches

Il est parfois utile d'attribuer des tranches dans une trace à une action utilisateur de niveau supérieur ou à un événement système. Par exemple, pour attribuer toutes les tranches qui correspondent à un travail en arrière-plan dans le cadre d'une notification, vous pouvez procéder comme suit :

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)
    }
}

Le résultat suivant est généré.

Capture d'écran d'une trace Perfetto avec des tranches corrélées

Figure 7. Capture d'écran d'une trace Perfetto avec des tranches corrélées.

Ajouter des informations sur la pile d'appels

Les outils côté hôte, tels que les plug-ins de compilateur et les processeurs d'annotations, peuvent également choisir d'intégrer des informations sur la pile d'appels dans une trace, afin de faciliter la localisation du fichier, de la classe ou de la méthode responsable de la production d'une section de trace dans une 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)
        }
    }
}

Le résultat suivant est généré.

Capture d'écran d'une trace Perfetto avec des informations sur la pile d'appels

Figure 8. Capture d'écran d'une trace Perfetto avec des informations sur la pile d'appels.