Skip to content

Commit

Permalink
feat(android): profiling (#980)
Browse files Browse the repository at this point in the history
* feat(android): support profiling
  • Loading branch information
Malinskiy authored Nov 10, 2024
1 parent 16c55ad commit afa0bdd
Show file tree
Hide file tree
Showing 12 changed files with 230 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.malinskiy.marathon.config.vendor.android.AdbEndpoint
import com.malinskiy.marathon.config.vendor.android.AllureConfiguration
import com.malinskiy.marathon.config.vendor.android.AndroidTestBundleConfiguration
import com.malinskiy.marathon.config.vendor.android.FileSyncConfiguration
import com.malinskiy.marathon.config.vendor.android.ProfilingConfiguration
import com.malinskiy.marathon.config.vendor.android.ScreenRecordConfiguration
import com.malinskiy.marathon.config.vendor.android.SerialStrategy
import com.malinskiy.marathon.config.vendor.android.TestAccessConfiguration
Expand Down Expand Up @@ -75,6 +76,7 @@ sealed class VendorConfiguration {
@JsonProperty("testAccessConfiguration") val testAccessConfiguration: TestAccessConfiguration = TestAccessConfiguration(),
@JsonProperty("adbServers") val adbServers: List<AdbEndpoint> = listOf(AdbEndpoint()),
@JsonProperty("disableWindowAnimation") val disableWindowAnimation: Boolean = DEFAULT_DISABLE_WINDOW_ANIMATION,
@JsonProperty("profilingConfiguration") val profilingConfiguration: ProfilingConfiguration = ProfilingConfiguration(),
) : VendorConfiguration() {
fun safeAndroidSdk(): File = androidSdk ?: throw ConfigurationException("No android SDK path specified")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.malinskiy.marathon.config.vendor.android

import com.fasterxml.jackson.annotation.JsonProperty
import java.io.File

data class ProfilingConfiguration(
@JsonProperty("enabled") val enabled: Boolean = false,
@JsonProperty("pbtxt") val pbtxt: File? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ data class Attachment(val file: File, val type: AttachmentType, val name: String
const val LOG = "log"
const val LOGCAT = "logcat"
const val XCODEBUILDLOG = "xcodebuild-log"
const val PROFILING = "profiling"
}
}

Expand All @@ -20,5 +21,6 @@ enum class AttachmentType(val mimeType: String) {
SCREENSHOT_PNG("image/png"),
SCREENSHOT_WEBP("image/webp"),
VIDEO("video/mp4"),
LOG("text/plain");
LOG("text/plain"),
PROFILING("application/octet-stream");
}
1 change: 1 addition & 0 deletions core/src/main/kotlin/com/malinskiy/marathon/io/FileType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ enum class FileType(val dir: String, val suffix: String) {
SCREENSHOT_GIF("screenshot", "jpg"),
XCTESTRUN("xctestrun", "xctestrun"),
BILL("bill", "json"),
TRACING("profiling", "profile"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.google.common.collect.ImmutableMap
import com.malinskiy.marathon.analytics.internal.sub.ExecutionReport
import com.malinskiy.marathon.config.Configuration
import com.malinskiy.marathon.device.DeviceInfo
import com.malinskiy.marathon.execution.AttachmentType
import com.malinskiy.marathon.execution.TestResult
import com.malinskiy.marathon.execution.TestStatus
import com.malinskiy.marathon.report.Reporter
Expand All @@ -15,6 +16,7 @@ import io.qameta.allure.AllureLifecycle
import io.qameta.allure.FileSystemResultsWriter
import io.qameta.allure.model.Attachment
import io.qameta.allure.model.Label
import io.qameta.allure.model.Link
import io.qameta.allure.model.Status
import io.qameta.allure.model.StatusDetails
import io.qameta.allure.util.ResultsUtils
Expand Down Expand Up @@ -77,10 +79,22 @@ class AllureReporter(val configuration: Configuration, private val outputDirecto
TestStatus.IGNORED -> Status.SKIPPED
}

val links = mutableListOf<Link>()

val allureAttachments: List<Attachment> = testResult.attachments.mapNotNull {
if (it.empty) {
null
} else {
when (it.type) {
AttachmentType.PROFILING -> links.add(
Link().apply {
setUrl("https://cloud.marathonlabs.io/trace/view?todo=x")
setName("Tracing")
}
)

else -> Unit
}
val name = it.name ?: it.type.name.lowercase(Locale.ENGLISH)
.replaceFirstChar { cher -> if (cher.isLowerCase()) cher.titlecase(Locale.ENGLISH) else cher.toString() }
Attachment()
Expand All @@ -100,6 +114,7 @@ class AllureReporter(val configuration: Configuration, private val outputDirecto
.setStop(testResult.endTime)
.setAttachments(allureAttachments)
.setParameters(emptyList())
.setLinks(links)
.setLabels(
mutableListOf(
ResultsUtils.createHostLabel().setValue(device.serialNumber),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.malinskiy.marathon.android.executor.listeners.TestResultsListener
import com.malinskiy.marathon.android.executor.listeners.filesync.FileSyncTestRunListener
import com.malinskiy.marathon.android.executor.listeners.screenshot.AdamScreenCaptureTestRunListener
import com.malinskiy.marathon.android.executor.listeners.screenshot.ScreenCapturerTestRunListener
import com.malinskiy.marathon.android.executor.listeners.profiling.ProfilingRunListener
import com.malinskiy.marathon.android.executor.listeners.video.ScreenRecorderTestBatchListener
import com.malinskiy.marathon.android.model.ShellCommandResult
import com.malinskiy.marathon.device.screenshot.Rotation
Expand Down Expand Up @@ -47,6 +48,7 @@ abstract class BaseAndroidDevice(
protected val serialStrategy: SerialStrategy,
protected val configuration: Configuration,
protected val androidConfiguration: VendorConfiguration.AndroidConfiguration,
protected val testBundleIdentifier: AndroidTestBundleIdentifier,
protected val track: Track,
protected val timer: Timer
) : AndroidDevice, CoroutineScope {
Expand Down Expand Up @@ -252,25 +254,48 @@ abstract class BaseAndroidDevice(
prepareRecorderListener(feature, fileManager, devicePoolId, testBatch.id, screenRecordingPolicy, attachmentProviders)
} ?: NoOpTestRunListener()

val profilingConfiguration = this@BaseAndroidDevice.androidConfiguration.profilingConfiguration
val profilingListener = if (profilingConfiguration.enabled && profilingConfiguration.pbtxt != null) {
ProfilingRunListener(
fileManager,
devicePoolId,
testBatch,
this,
profilingConfiguration,
testBundleIdentifier,
this
).also { attachmentProviders.add(it) }
} else {
NoOpTestRunListener()
}

val logListener = TestRunListenerAdapter(
LogListener(this.toDeviceInfo(), this, devicePoolId, testBatch.id, LogWriter(fileManager), attachmentName = Attachment.Name.LOGCAT)
.also { attachmentProviders.add(it) }
LogListener(
this.toDeviceInfo(),
this,
devicePoolId,
testBatch.id,
LogWriter(fileManager),
attachmentName = Attachment.Name.LOGCAT
)
.also { attachmentProviders.add(it) }
)

val fileSyncTestRunListener =
FileSyncTestRunListener(devicePoolId, this, this@BaseAndroidDevice.androidConfiguration.fileSyncConfiguration, fileManager)

val adamScreenCaptureTestRunListener = AdamScreenCaptureTestRunListener(devicePoolId, this, fileManager, testBatch.id)
attachmentProviders.add(adamScreenCaptureTestRunListener)

return CompositeTestRunListener(
listOf(
recorderListener,
logListener,
TestResultsListener(testBatch, this, deferred, timer, devicePoolId, attachmentProviders),
DebugTestRunListener(this),
adamScreenCaptureTestRunListener,
fileSyncTestRunListener
fileSyncTestRunListener,
profilingListener,
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,26 @@ class RemoteFileManager(private val device: AndroidDevice) {
return remoteFileForTest(videoFileName(test, testBatchId, chunk))
}

fun remoteProfilingForTest(test: Test, testBatchId: String): String {
return "$TRACE_ROOT/${traceFileName(test, testBatchId)}"
}

private fun remoteFileForTest(filename: String): String {
return "$outputDir/$filename"
}

private fun traceFileName(test: Test, testBatchId: String): String {
return remoteFileName(test, testBatchId, extension = "perfetto-trace", chunk = null)
}

private fun videoFileName(test: Test, testBatchId: String, chunk: Long? = null): String {
return remoteFileName(test, testBatchId, extension = "mp4", chunk = chunk)

}

private fun remoteFileName(test: Test, testBatchId: String, extension: String, chunk: Long? = null): String {
val chunkId = chunk?.let { "-$it" } ?: ""
val testSuffix = "-$testBatchId$chunkId.mp4"
val testSuffix = "-$testBatchId$chunkId.$extension"
val rawTestName = "${test.toClassName()}-${test.method}".escape()
val testName = rawTestName.take(MAX_FILENAME - testSuffix.length)
val fileName = "$testName$testSuffix"
Expand All @@ -49,5 +62,7 @@ class RemoteFileManager(private val device: AndroidDevice) {
companion object {
const val MAX_FILENAME = 255
const val TMP_PATH = "/data/local/tmp"
const val TRACE_ROOT = "/data/misc/perfetto-traces"
const val TRACE_CONFIG_FILE = "$TMP_PATH/tracing.pbtx"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,15 @@ class AdamAndroidDevice(
internal val client: AndroidDebugBridgeClient,
private val deviceStateTracker: DeviceStateTracker,
private val logcatManager: LogcatManager,
private val testBundleIdentifier: AndroidTestBundleIdentifier,
private val installContext: CoroutineContext,
adbSerial: String,
configuration: Configuration,
androidConfiguration: VendorConfiguration.AndroidConfiguration,
testBundleIdentifier: AndroidTestBundleIdentifier,
track: Track,
timer: Timer,
serialStrategy: SerialStrategy
) : BaseAndroidDevice(adbSerial, serialStrategy, configuration, androidConfiguration, track, timer), LineListener {
) : BaseAndroidDevice(adbSerial, serialStrategy, configuration, androidConfiguration, testBundleIdentifier, track, timer), LineListener {

/**
* This adapter is thread-safe but the internal reusable buffer should be considered if we ever need to make screenshots in parallel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,11 @@ class AdamDeviceProvider(
client,
multiServerDeviceStateTracker.getTracker(client),
logcatManager,
testBundleIdentifier,
installDispatcher,
serial,
configuration,
vendorConfiguration,
testBundleIdentifier,
track,
timer,
vendorConfiguration.serialStrategy
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.malinskiy.marathon.android.executor.listeners.profiling

import com.malinskiy.marathon.android.AndroidDevice
import com.malinskiy.marathon.android.AndroidTestBundleIdentifier
import com.malinskiy.marathon.android.InstrumentationInfo
import com.malinskiy.marathon.android.executor.listeners.NoOpTestRunListener
import com.malinskiy.marathon.android.model.TestIdentifier
import com.malinskiy.marathon.config.vendor.android.ProfilingConfiguration
import com.malinskiy.marathon.device.DevicePoolId
import com.malinskiy.marathon.device.toDeviceInfo
import com.malinskiy.marathon.exceptions.TransferException
import com.malinskiy.marathon.execution.Attachment
import com.malinskiy.marathon.execution.AttachmentType
import com.malinskiy.marathon.io.FileManager
import com.malinskiy.marathon.io.FileType
import com.malinskiy.marathon.log.MarathonLogging
import com.malinskiy.marathon.report.attachment.AttachmentListener
import com.malinskiy.marathon.report.attachment.AttachmentProvider
import com.malinskiy.marathon.test.TestBatch
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.supervisorScope
import org.apache.commons.text.StringSubstitutor
import org.apache.commons.text.lookup.StringLookupFactory
import kotlin.coroutines.cancellation.CancellationException
import kotlin.system.measureTimeMillis


class ProfilingRunListener(
private val fileManager: FileManager,
private val pool: DevicePoolId,
private val testBatch: TestBatch,
private val device: AndroidDevice,
private val profilingConfiguration: ProfilingConfiguration,
private val testBundleIdentifier: AndroidTestBundleIdentifier,
coroutineScope: CoroutineScope
) : NoOpTestRunListener(), AttachmentProvider, CoroutineScope by coroutineScope {
private val logger = MarathonLogging.logger("ProfilingRunListener")

private var job: Job? = null
private val attachmentListeners = mutableListOf<AttachmentListener>()
private var targetPid: Int? = null
private var tracingConfig: String? = null
private var renderedConfig: String? = null

override fun registerListener(listener: AttachmentListener) {
attachmentListeners.add(listener)
}

override suspend fun beforeTestRun(info: InstrumentationInfo?) {
super.beforeTestRun(info)
tracingConfig = profilingConfiguration.pbtxt?.readText()
}

override suspend fun testRunStarted(runName: String, testCount: Int) {
super.testRunStarted(runName, testCount)

// Assumption is that we can never execute test batches with multiple packages
val testBundle = testBundleIdentifier.identify(testBatch.tests.first())
val result = device.criticalExecuteShellCommand("pidof ${testBundle.instrumentationInfo.applicationPackage}")
//TODO: check app is profileable and debuggable for java heap profiling

targetPid = result.output.trim().toIntOrNull()
logger.debug { "Target pid: $targetPid" }

if (tracingConfig != null) {
val lookup = StringSubstitutor(
StringLookupFactory.INSTANCE.mapStringLookup(
mapOf(
"TRACING_TARGET_PID" to targetPid,
"TRACING_TARGET_PACKAGE" to testBundle.instrumentationInfo.applicationPackage
)
)
)
renderedConfig = lookup.replace(tracingConfig)

logger.debug { "Rendered config:\n$renderedConfig" }
}
}

override suspend fun testStarted(test: TestIdentifier) {
super.testStarted(test)

val remoteFile = device.fileManager.remoteProfilingForTest(test.toTest(), testBatch.id)

job = async(coroutineContext + CoroutineName("perfetto ${device.serialNumber}")) {
supervisorScope {
try {
val result =
device.executeShellCommand("echo '${renderedConfig}' | perfetto --txt -c - -o $remoteFile")
logger.debug { "perfetto process finished: $result" }
} catch (e: CancellationException) {
logger.warn(e) { "perfetto start was interrupted" }
throw e
} catch (e: Exception) {
logger.error("Something went wrong while recording perfetto trace", e)
throw e
}
}
}
}

override suspend fun testEnded(test: TestIdentifier, testMetrics: Map<String, String>) {
super.testEnded(test, testMetrics)
pullTrace(test)
logger.debug { "Finished processing" }
}

private suspend fun pullTrace(test: TestIdentifier) {
try {
stop()

val test = test.toTest()
val remoteFile = device.fileManager.remoteProfilingForTest(test, testBatch.id)
val localFile = fileManager.createFile(FileType.TRACING, pool, device.toDeviceInfo(), test, testBatch.id)
val millis = measureTimeMillis {
device.safePullFile(remoteFile, localFile.toString())
}
logger.trace { "Pulling finished in ${millis}ms $remoteFile " }

attachmentListeners.forEach {
it.onAttachment(
test,
Attachment(localFile, AttachmentType.PROFILING, name = Attachment.Name.PROFILING)
)
}

/**
* Read-only partition hence -f is required
*/
device.safeExecuteShellCommand("rm -f $remoteFile")
} catch (e: TransferException) {
logger.warn { "Can't pull tracing file" }
}
}

private suspend fun stop() {
logger.debug { "Stopping perfetto" }
val stop = measureTimeMillis {
device.safeExecuteShellCommand("killall perfetto")
}
logger.debug { "Stopped perfetto: ${stop}ms" }
val join = measureTimeMillis {
job?.join()
}
logger.debug { "Joining perfetto: ${join}ms" }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class ScreenRecorderTestBatchListener(
attachmentListeners.add(listener)
}

private val logger = MarathonLogging.logger("ScreenRecorder")
private val logger = MarathonLogging.logger("ScreenRecorderTestBatchListener")

private val screenRecorder = ScreenRecorder(device, videoConfiguration)

Expand Down
Loading

0 comments on commit afa0bdd

Please sign in to comment.