Registro en proceso (experimental)

La nueva biblioteca androidx.tracing:tracing:2.0.0-alpha01 es una API de Kotlin de baja sobrecarga que permite capturar eventos de seguimiento en el proceso. Estos eventos pueden capturar períodos y su contexto. Además, la biblioteca admite la propagación de contexto para las corrutinas de Kotlin.

La biblioteca usa el mismo formato de paquete de registro de Perfetto que conocen los desarrolladores de Android. Además, Tracing 2.0 (a diferencia de las APIs de 1.0.0-*) admite la noción de backends de seguimiento conectables y receptores, por lo que otras bibliotecas de seguimiento pueden personalizar el formato de seguimiento de salida y cómo funciona la propagación del contexto en su implementación.

Dependencias

Para comenzar el registro, debes definir las siguientes dependencias en tu build.gradle.kts.

kotlin {
  androidLibrary {
    namespace = "com.example.library"
    // ...
  }
  sourceSets {
    androidMain {
      dependencies {
        api("androidx.tracing:tracing-wire-android:2.0.0-alpha01")
        // ...
      }
    }
    jvmMain {
      dependencies {
        api("androidx.tracing:tracing-wire-desktop:2.0.0-alpha01")
        // ...
      }
    }
  }
}

Declara una dependencia en androidx.tracing:tracing-wire-android:2.0.0-alpha01 si tu objetivo es una biblioteca o una aplicación para Android. Puedes usar la dependencia androidx.tracing:tracing-wire-desktop:2.0.0-alpha01 si segmentas tu aplicación para la JVM.

Uso básico

Un TraceSink define cómo se serializan los paquetes de seguimiento. La versión 2.0.0 de Tracing incluye una implementación de un receptor que usa el formato de paquete de seguimiento Perfetto. Un TraceDriver proporciona un identificador para el Tracer y se puede usar para finalizar un registro.

También puedes usar TraceDriver para inhabilitar todos los puntos de seguimiento en la aplicación si decides no realizar el seguimiento en algunas variantes de la aplicación. Las futuras APIs de TraceDriver también permitirán a los desarrolladores controlar qué categorías de registro les interesa capturar (o inhabilitar cuando una categoría es ruidosa).

Para comenzar, crea una instancia de TraceSink y TraceDriver.

/**
 * A [TraceSink] defines how traces are serialized.
 *
 * [androidx.tracing.wire.TraceSink] uses the `Perfetto` trace packet format.
 */
fun createSink(): TraceSink {
    val outputDirectory = File(/* path = */ "/tmp/perfetto")
    if (!outputDirectory.exists()) {
        outputDirectory.mkdirs()
    }
    // We are using the factory function defined in androidx.tracing.wire
    return TraceSink(
        sequenceId = 1,
        directory = outputDirectory
    )
}
/**
 * Creates a new instance of [androidx.tracing.TraceDriver].
 */
fun createTraceDriver(): TraceDriver {
    // We are using a factory function from androidx.tracing.wire here.
    // `isEnabled` controls whether tracing is enabled for the application.
    val driver = TraceDriver(sink = createSink(), isEnabled = true)
    return driver
}

Después de obtener una instancia de TraceDriver, obtén el Tracer que define el punto de entrada para todas las APIs de seguimiento.

// Tracing Categories identify subsystems that are responsible
// in generating trace sections. Future APIs in `TraceDriver` will allow the
// application to specify which categories they are interested in tracing.
// This lets the application disable entire trace categories, without
// needing to disable trace instrumentation at the call sites for those
// categories.

internal const val CATEGORY_MAIN = "main"

fun main() {
    val driver = createTraceDriver()
    driver.use {
        driver.tracer.trace(category = CATEGORY_MAIN, name = "basic") {
            Thread.sleep(100L)
        }
    }
}

Esto genera el siguiente registro.

Captura de pantalla de un registro básico de Perfetto

Figura 1: Captura de pantalla de un registro básico de Perfetto.

Puedes ver que se propagaron los registros de proceso y subproceso correctos, y se produjo una sola sección de registro basic, que se ejecutó durante 100ms.

Las secciones (o segmentos) de registro se pueden anidar en el mismo segmento para representar eventos superpuestos. A continuación, se muestra un ejemplo.

fun main() {
    // Initialize the tracing infrastructure to monitor app performance
    val driver = createTraceDriver()
    val tracer = driver.tracer
    driver.use {
        tracer.trace(
            category = CATEGORY_MAIN,
            name = "processImage",
        ) {
            // Load the data first, then apply the sharpen filter
            sharpen(tracer = tracer, output = loadImage(tracer))
        }
    }
}

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

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

Esto genera el siguiente registro.

Captura de pantalla de un registro básico de Perfetto con secciones anidadas

Figura 2: Captura de pantalla de un registro básico de Perfetto con secciones anidadas.

Puedes ver que hay eventos superpuestos en el segmento del subproceso principal. Es muy claro que processImage llama a loadImage y sharpen en el mismo subproceso.

Agrega metadatos adicionales en las secciones de seguimiento

A veces, puede ser útil adjuntar metadatos contextuales adicionales a un segmento de registro para obtener más detalles. Algunos ejemplos de estos metadatos podrían incluir el nav destination en el que se encuentra el usuario o el input arguments que podría determinar cuánto tiempo lleva una función.

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

Esto produce el siguiente resultado. Ten en cuenta que la sección Arguments contiene pares clave-valor agregados cuando se produce el slice.

Captura de pantalla de un registro básico de Perfetto con metadatos adicionales

Figura 3: Captura de pantalla de un registro básico de Perfetto con metadatos adicionales.

Propagación del contexto

Cuando se usan corrutinas de Kotlin (o cualquier otro framework similar que ayude con cargas de trabajo simultáneas), Tracing 2.0 admite la noción de propagación de contexto. Esto se explica mejor con un ejemplo.

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

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

fun main() = runBlocking(context = Dispatchers.Default) {
    val driver = createTraceDriver()
    val tracer = driver.tracer
    driver.use {
        tracer.traceCoroutine(category = CATEGORY_MAIN, name = "main") {
            coroutineScope {
                launch { taskOne(tracer) }
                launch { taskTwo(tracer) }
            }
        }
        println("All done")
    }
}

Esto produce el siguiente resultado.

Captura de pantalla de un registro de Perfetto con propagación del contexto

Figura 4: Captura de pantalla de un registro básico de Perfetto con propagación del contexto.

La propagación del contexto facilita mucho la visualización del flujo de ejecución. Puedes ver exactamente qué tareas estaban relacionadas (conectadas con otras) y cuándo se suspendieron y reanudaron las Threads.

Por ejemplo, puedes ver que la segmentación main generó taskOne y taskTwo. Después de eso, ambos subprocesos quedaron inactivos (dado que las corrutinas se suspendieron debido al uso de delay).

Propagación manual

A veces, cuando combinas cargas de trabajo simultáneas con corrutinas de Kotlin y con instancias de Executor de Java, puede ser útil propagar el contexto de una a otra. A continuación, se muestra un ejemplo:

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

@OptIn(DelicateTracingApi::class)
fun main() = runBlocking(context = Dispatchers.Default) {
    val driver = createTraceDriver()
    val executor = Executors.newSingleThreadExecutor()
    val tracer = driver.tracer
    driver.use {
        tracer.traceCoroutine(category = CATEGORY_MAIN, name = "main") {
            coroutineScope {
                val deferred = CompletableDeferred<Unit>()
                executorTask(
                    tracer = tracer,
                    // Obtain the propagation token from the CoroutineContext
                    token = tracer.tokenFromCoroutineContext(),
                    executor = executor,
                    callback = {
                        deferred.complete(Unit)
                    }
                )
                deferred.await()
            }
        }
        executor.shutdownNow()
    }
}

Esto produce el siguiente resultado.

Captura de pantalla de un registro de Perfetto con propagación manual del contexto

Figura 5: Captura de pantalla de un registro básico de Perfetto con propagación manual del contexto.

Puedes ver que la ejecución comenzó en un CoroutineContext y, luego, cambió a un Executor de Java, pero pudimos seguir usando la propagación del contexto.

Combina con registros del sistema

El nuevo androidx.tracing no captura información como la programación de la CPU, el uso de memoria y la interacción de las aplicaciones con el sistema operativo en general. Esto se debe a que la biblioteca proporciona una forma de realizar un seguimiento en el proceso con una sobrecarga muy baja.

Sin embargo, es muy sencillo combinar los registros del sistema con los registros en el proceso y visualizarlos como un solo registro si es necesario. Esto se debe a que Perfetto UI admite la visualización de varios archivos de registro de un dispositivo en una línea de tiempo unificada.

Para ello, puedes iniciar una sesión de registro del sistema con Perfetto UI siguiendo las instrucciones que se indican aquí.

También puedes registrar eventos de registro en proceso con la API de Tracing 2.0 mientras el registro del sistema está activado. Una vez que tengas ambos archivos de registro, puedes usar la opción Open Multiple Trace Files en Perfetto.

Cómo abrir varios archivos de registro en la IU de Perfetto

Figura 6: Apertura de varios archivos de registro en la IU de Perfetto

Flujos de trabajo avanzados

Correlaciona segmentos

A veces, es útil atribuir segmentos en un registro a una acción del usuario de nivel superior o a un evento del sistema. Por ejemplo, para atribuir todos los segmentos que corresponden a algún trabajo en segundo plano como parte de una notificación, podrías hacer algo como lo siguiente:

fun main() {
    val driver = createTraceDriver()
    onEvent(driver, eventId = EVENT_ID)
}

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

        Thread.sleep(20)

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

Esto produce el siguiente resultado.

Captura de pantalla de un registro de Perfetto con segmentos correlacionados

Figura 7: Captura de pantalla de un registro de Perfetto con segmentos correlacionados.

Agrega información de la pila de llamadas

Las herramientas del host (como los complementos del compilador y los procesadores de anotaciones) también pueden optar por incorporar información de la pila de llamadas en un registro para que sea más fácil ubicar el archivo, la clase o el método responsable de producir una sección de registro en un registro.

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

Esto produce el siguiente resultado.

Captura de pantalla de un registro de Perfetto con información de la pila de llamadas

Figura 8: Captura de pantalla de un registro de Perfetto con información de la pila de llamadas.