diff --git a/example/build.gradle.kts b/example/build.gradle.kts index d19b5139aa..ca11275cca 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -1,3 +1,4 @@ +import org.gradle.internal.impldep.org.junit.experimental.categories.Categories.CategoryFilter.include import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { @@ -17,8 +18,9 @@ tasks.named("compileKotlin", KotlinCompilationTask::class.java) { } dependencies { - implementation("dev.mobile:maestro-client:1.38.1") - implementation("dev.mobile:maestro-orchestra:1.38.1") - implementation("dev.mobile:maestro-ios:1.38.1") + implementation(project(":maestro-utils")) + implementation(project(":maestro-client")) + implementation(project(":maestro-orchestra")) + implementation(project(":maestro-ios")) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 411c45e8f4..f019172bb1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,8 @@ junit = "5.10.2" kotlin = "1.8.22" kotlinResult = "1.1.18" ktor = "2.3.6" +micrometerObservation = "1.13.4" +micrometerCore = "1.13.4" mockk = "1.12.0" mozillaRhino = "1.7.14" picocli = "4.6.3" @@ -101,6 +103,8 @@ ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" } ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" } +micrometer-core = { module = "io.micrometer:micrometer-core", version.ref = "micrometerCore" } +micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "micrometerObservation" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mozilla-rhino = { module = "org.mozilla:rhino", version.ref = "mozillaRhino" } picocli = { module = "info.picocli:picocli", version.ref = "picocli" } diff --git a/maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt b/maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt index 7a315dd8fc..6cdf0fa7b8 100644 --- a/maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt +++ b/maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt @@ -15,10 +15,10 @@ import maestro.cli.runner.resultview.AnsiResultView import maestro.cli.util.CiUtils import maestro.cli.util.EnvUtils import maestro.cli.util.PrintUtils +import maestro.utils.HttpClient import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody -import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody @@ -32,21 +32,21 @@ import okio.IOException import okio.buffer import java.io.File import java.nio.file.Path -import java.util.UUID -import java.util.concurrent.TimeUnit import kotlin.io.path.absolutePathString import kotlin.io.path.exists +import kotlin.time.Duration.Companion.minutes class ApiClient( private val baseUrl: String, ) { - private val client = OkHttpClient.Builder() - .readTimeout(5, TimeUnit.MINUTES) - .writeTimeout(5, TimeUnit.MINUTES) - .protocols(listOf(Protocol.HTTP_1_1)) - .addInterceptor(SystemInformationInterceptor()) - .build() + private val client = HttpClient.build( + name = "ApiClient", + readTimeout = 5.minutes, + writeTimeout = 5.minutes, + protocols = listOf(Protocol.HTTP_1_1), + interceptors = listOf(SystemInformationInterceptor()), + ) val domain: String get() { diff --git a/maestro-cli/src/main/java/maestro/cli/util/ChangeLogUtils.kt b/maestro-cli/src/main/java/maestro/cli/util/ChangeLogUtils.kt index 8015f675ad..c02b31e52e 100644 --- a/maestro-cli/src/main/java/maestro/cli/util/ChangeLogUtils.kt +++ b/maestro-cli/src/main/java/maestro/cli/util/ChangeLogUtils.kt @@ -1,9 +1,9 @@ package maestro.cli.util -import okhttp3.OkHttpClient +import maestro.cli.util.EnvUtils.CLI_VERSION +import maestro.utils.HttpClient import okhttp3.Request import java.io.File -import maestro.cli.util.EnvUtils.CLI_VERSION typealias ChangeLog = List? @@ -21,7 +21,7 @@ object ChangeLogUtils { val request = Request.Builder() .url("https://raw.githubusercontent.com/mobile-dev-inc/maestro/main/CHANGELOG.md") .build() - return OkHttpClient().newCall(request).execute().body?.string() + return HttpClient.build("ChangeLogUtils").newCall(request).execute().body?.string() } fun print(changelog: ChangeLog): String = diff --git a/maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt b/maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt index 1cb640093e..c5404b184c 100644 --- a/maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt +++ b/maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt @@ -36,6 +36,8 @@ import maestro.android.AndroidAppFiles import maestro.android.AndroidLaunchArguments.toAndroidLaunchArguments import maestro.utils.BlockingStreamObserver import maestro.utils.MaestroTimer +import maestro.utils.Metrics +import maestro.utils.MetricsProvider import maestro.utils.ScreenshotUtils import maestro.utils.StringUtils.toRegexSafe import maestro_android.* @@ -60,10 +62,13 @@ class AndroidDriver( private val dadb: Dadb, hostPort: Int? = null, private var emulatorName: String = "", -) : Driver { + private val metricsProvider: Metrics = MetricsProvider.getInstance(), + ) : Driver { private var open = false private val hostPort: Int = hostPort ?: DefaultDriverHostPort + private val metrics = metricsProvider.withPrefix("maestro.driver").withTags(mapOf("platform" to "android", "emulatorName" to emulatorName)) + private val channel = ManagedChannelBuilder.forAddress("localhost", this.hostPort) .usePlaintext() .build() @@ -194,42 +199,50 @@ class AndroidDriver( launchArguments: Map, sessionId: UUID?, ) { - if(!open) // pick device flow, no open() invocation - open() + metrics.measured("operation", mapOf("command" to "launchApp", "appId" to appId)) { + if(!open) // pick device flow, no open() invocation + open() - if (!isPackageInstalled(appId)) { - throw IllegalArgumentException("Package $appId is not installed") - } + if (!isPackageInstalled(appId)) { + throw IllegalArgumentException("Package $appId is not installed") + } - val arguments = launchArguments.toAndroidLaunchArguments() - val sessionUUID = sessionId ?: UUID.randomUUID() - dadb.shell("setprop debug.maestro.sessionId $sessionUUID") - runDeviceCall { - blockingStubWithTimeout.launchApp( - launchAppRequest { - this.packageName = appId - this.arguments.addAll(arguments) - } - ) ?: throw IllegalStateException("Maestro driver failed to launch app") + val arguments = launchArguments.toAndroidLaunchArguments() + val sessionUUID = sessionId ?: UUID.randomUUID() + dadb.shell("setprop debug.maestro.sessionId $sessionUUID") + runDeviceCall { + blockingStubWithTimeout.launchApp( + launchAppRequest { + this.packageName = appId + this.arguments.addAll(arguments) + } + ) ?: throw IllegalStateException("Maestro driver failed to launch app") + } } } override fun stopApp(appId: String) { - // Note: If the package does not exist, this call does *not* throw an exception - shell("am force-stop $appId") + metrics.measured("operation", mapOf("command" to "stopApp", "appId" to appId)) { + // Note: If the package does not exist, this call does *not* throw an exception + shell("am force-stop $appId") + } } override fun killApp(appId: String) { - // Kill is the adb command needed to trigger System-initiated Process Death - shell("am kill $appId") + metrics.measured("operation", mapOf("command" to "killApp", "appId" to appId)) { + // Kill is the adb command needed to trigger System-initiated Process Death + shell("am kill $appId") + } } override fun clearAppState(appId: String) { - if (!isPackageInstalled(appId)) { - return - } + metrics.measured("operation", mapOf("command" to "clearAppState", "appId" to appId)) { + if (!isPackageInstalled(appId)) { + return@measured + } - shell("pm clear $appId") + shell("pm clear $appId") + } } override fun clearKeychain() { @@ -237,70 +250,78 @@ class AndroidDriver( } override fun tap(point: Point) { - runDeviceCall { - blockingStubWithTimeout.tap( - tapRequest { - x = point.x - y = point.y - } - ) ?: throw IllegalStateException("Response can't be null") + metrics.measured("operation", mapOf("command" to "tap")) { + runDeviceCall { + blockingStubWithTimeout.tap( + tapRequest { + x = point.x + y = point.y + } + ) ?: throw IllegalStateException("Response can't be null") + } } } override fun longPress(point: Point) { - dadb.shell("input swipe ${point.x} ${point.y} ${point.x} ${point.y} 3000") + metrics.measured("operation", mapOf("command" to "longPress")) { + dadb.shell("input swipe ${point.x} ${point.y} ${point.x} ${point.y} 3000") + } } override fun pressKey(code: KeyCode) { - val intCode: Int = when (code) { - KeyCode.ENTER -> 66 - KeyCode.BACKSPACE -> 67 - KeyCode.BACK -> 4 - KeyCode.VOLUME_UP -> 24 - KeyCode.VOLUME_DOWN -> 25 - KeyCode.HOME -> 3 - KeyCode.LOCK -> 276 - KeyCode.REMOTE_UP -> 19 - KeyCode.REMOTE_DOWN -> 20 - KeyCode.REMOTE_LEFT -> 21 - KeyCode.REMOTE_RIGHT -> 22 - KeyCode.REMOTE_CENTER -> 23 - KeyCode.REMOTE_PLAY_PAUSE -> 85 - KeyCode.REMOTE_STOP -> 86 - KeyCode.REMOTE_NEXT -> 87 - KeyCode.REMOTE_PREVIOUS -> 88 - KeyCode.REMOTE_REWIND -> 89 - KeyCode.REMOTE_FAST_FORWARD -> 90 - KeyCode.POWER -> 26 - KeyCode.ESCAPE -> 111 - KeyCode.TAB -> 62 - KeyCode.REMOTE_SYSTEM_NAVIGATION_UP -> 280 - KeyCode.REMOTE_SYSTEM_NAVIGATION_DOWN -> 281 - KeyCode.REMOTE_BUTTON_A -> 96 - KeyCode.REMOTE_BUTTON_B -> 97 - KeyCode.REMOTE_MENU -> 82 - KeyCode.TV_INPUT -> 178 - KeyCode.TV_INPUT_HDMI_1 -> 243 - KeyCode.TV_INPUT_HDMI_2 -> 244 - KeyCode.TV_INPUT_HDMI_3 -> 245 - } - - dadb.shell("input keyevent $intCode") - Thread.sleep(300) + metrics.measured("operation", mapOf("command" to "pressKey")) { + val intCode: Int = when (code) { + KeyCode.ENTER -> 66 + KeyCode.BACKSPACE -> 67 + KeyCode.BACK -> 4 + KeyCode.VOLUME_UP -> 24 + KeyCode.VOLUME_DOWN -> 25 + KeyCode.HOME -> 3 + KeyCode.LOCK -> 276 + KeyCode.REMOTE_UP -> 19 + KeyCode.REMOTE_DOWN -> 20 + KeyCode.REMOTE_LEFT -> 21 + KeyCode.REMOTE_RIGHT -> 22 + KeyCode.REMOTE_CENTER -> 23 + KeyCode.REMOTE_PLAY_PAUSE -> 85 + KeyCode.REMOTE_STOP -> 86 + KeyCode.REMOTE_NEXT -> 87 + KeyCode.REMOTE_PREVIOUS -> 88 + KeyCode.REMOTE_REWIND -> 89 + KeyCode.REMOTE_FAST_FORWARD -> 90 + KeyCode.POWER -> 26 + KeyCode.ESCAPE -> 111 + KeyCode.TAB -> 62 + KeyCode.REMOTE_SYSTEM_NAVIGATION_UP -> 280 + KeyCode.REMOTE_SYSTEM_NAVIGATION_DOWN -> 281 + KeyCode.REMOTE_BUTTON_A -> 96 + KeyCode.REMOTE_BUTTON_B -> 97 + KeyCode.REMOTE_MENU -> 82 + KeyCode.TV_INPUT -> 178 + KeyCode.TV_INPUT_HDMI_1 -> 243 + KeyCode.TV_INPUT_HDMI_2 -> 244 + KeyCode.TV_INPUT_HDMI_3 -> 245 + } + + dadb.shell("input keyevent $intCode") + Thread.sleep(300) + } } override fun contentDescriptor(excludeKeyboardElements: Boolean): TreeNode { - val response = callViewHierarchy() + return metrics.measured("operation", mapOf("command" to "contentDescriptor")) { + val response = callViewHierarchy() - val document = documentBuilderFactory - .newDocumentBuilder() - .parse(response.hierarchy.byteInputStream()) + val document = documentBuilderFactory + .newDocumentBuilder() + .parse(response.hierarchy.byteInputStream()) - val treeNode = mapHierarchy(document) - return if (excludeKeyboardElements) { - treeNode.excludeKeyboardElements() ?: treeNode - } else { - treeNode + val treeNode = mapHierarchy(document) + if (excludeKeyboardElements) { + treeNode.excludeKeyboardElements() ?: treeNode + } else { + treeNode + } } } @@ -347,19 +368,23 @@ class AndroidDriver( } override fun scrollVertical() { - swipe(SwipeDirection.UP, 400) + metrics.measured("operation", mapOf("command" to "scrollVertical")) { + swipe(SwipeDirection.UP, 400) + } } override fun isKeyboardVisible(): Boolean { - val root = contentDescriptor().let { - val deviceInfo = deviceInfo() - val filtered = it.filterOutOfBounds( - width = deviceInfo.widthGrid, - height = deviceInfo.heightGrid - ) - filtered ?: it + return metrics.measured("operation", mapOf("command" to "isKeyboardVisible")) { + val root = contentDescriptor().let { + val deviceInfo = deviceInfo() + val filtered = it.filterOutOfBounds( + width = deviceInfo.widthGrid, + height = deviceInfo.heightGrid + ) + filtered ?: it + } + "com.google.android.inputmethod.latin:id" in jacksonObjectMapper().writeValueAsString(root) } - return "com.google.android.inputmethod.latin:id" in jacksonObjectMapper().writeValueAsString(root) } override fun swipe(start: Point, end: Point, durationMs: Long) { @@ -367,150 +392,169 @@ class AndroidDriver( } override fun swipe(swipeDirection: SwipeDirection, durationMs: Long) { - val deviceInfo = deviceInfo() - when (swipeDirection) { - SwipeDirection.UP -> { - val startX = (deviceInfo.widthGrid * 0.5f).toInt() - val startY = (deviceInfo.heightGrid * 0.5f).toInt() - val endX = (deviceInfo.widthGrid * 0.5f).toInt() - val endY = (deviceInfo.heightGrid * 0.1f).toInt() - directionalSwipe( - durationMs, - Point(startX, startY), - Point(endX, endY) - ) - } + metrics.measured("operation", mapOf("command" to "swipeWithDirection", "direction" to swipeDirection.name, "durationMs" to durationMs.toString())) { + val deviceInfo = deviceInfo() + when (swipeDirection) { + SwipeDirection.UP -> { + val startX = (deviceInfo.widthGrid * 0.5f).toInt() + val startY = (deviceInfo.heightGrid * 0.5f).toInt() + val endX = (deviceInfo.widthGrid * 0.5f).toInt() + val endY = (deviceInfo.heightGrid * 0.1f).toInt() + directionalSwipe( + durationMs, + Point(startX, startY), + Point(endX, endY) + ) + } - SwipeDirection.DOWN -> { - val startX = (deviceInfo.widthGrid * 0.5f).toInt() - val startY = (deviceInfo.heightGrid * 0.2f).toInt() - val endX = (deviceInfo.widthGrid * 0.5f).toInt() - val endY = (deviceInfo.heightGrid * 0.9f).toInt() - directionalSwipe( - durationMs, - Point(startX, startY), - Point(endX, endY) - ) - } + SwipeDirection.DOWN -> { + val startX = (deviceInfo.widthGrid * 0.5f).toInt() + val startY = (deviceInfo.heightGrid * 0.2f).toInt() + val endX = (deviceInfo.widthGrid * 0.5f).toInt() + val endY = (deviceInfo.heightGrid * 0.9f).toInt() + directionalSwipe( + durationMs, + Point(startX, startY), + Point(endX, endY) + ) + } - SwipeDirection.RIGHT -> { - val startX = (deviceInfo.widthGrid * 0.1f).toInt() - val startY = (deviceInfo.heightGrid * 0.5f).toInt() - val endX = (deviceInfo.widthGrid * 0.9f).toInt() - val endY = (deviceInfo.heightGrid * 0.5f).toInt() - directionalSwipe( - durationMs, - Point(startX, startY), - Point(endX, endY) - ) - } + SwipeDirection.RIGHT -> { + val startX = (deviceInfo.widthGrid * 0.1f).toInt() + val startY = (deviceInfo.heightGrid * 0.5f).toInt() + val endX = (deviceInfo.widthGrid * 0.9f).toInt() + val endY = (deviceInfo.heightGrid * 0.5f).toInt() + directionalSwipe( + durationMs, + Point(startX, startY), + Point(endX, endY) + ) + } - SwipeDirection.LEFT -> { - val startX = (deviceInfo.widthGrid * 0.9f).toInt() - val startY = (deviceInfo.heightGrid * 0.5f).toInt() - val endX = (deviceInfo.widthGrid * 0.1f).toInt() - val endY = (deviceInfo.heightGrid * 0.5f).toInt() - directionalSwipe( - durationMs, - Point(startX, startY), - Point(endX, endY) - ) + SwipeDirection.LEFT -> { + val startX = (deviceInfo.widthGrid * 0.9f).toInt() + val startY = (deviceInfo.heightGrid * 0.5f).toInt() + val endX = (deviceInfo.widthGrid * 0.1f).toInt() + val endY = (deviceInfo.heightGrid * 0.5f).toInt() + directionalSwipe( + durationMs, + Point(startX, startY), + Point(endX, endY) + ) + } } } } override fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long) { - val deviceInfo = deviceInfo() - when (direction) { - SwipeDirection.UP -> { - val endY = (deviceInfo.heightGrid * 0.1f).toInt() - directionalSwipe(durationMs, elementPoint, Point(elementPoint.x, endY)) - } + metrics.measured("operation", mapOf("command" to "swipeWithElementPoint", "direction" to direction.name, "durationMs" to durationMs.toString())) { + val deviceInfo = deviceInfo() + when (direction) { + SwipeDirection.UP -> { + val endY = (deviceInfo.heightGrid * 0.1f).toInt() + directionalSwipe(durationMs, elementPoint, Point(elementPoint.x, endY)) + } - SwipeDirection.DOWN -> { - val endY = (deviceInfo.heightGrid * 0.9f).toInt() - directionalSwipe(durationMs, elementPoint, Point(elementPoint.x, endY)) - } + SwipeDirection.DOWN -> { + val endY = (deviceInfo.heightGrid * 0.9f).toInt() + directionalSwipe(durationMs, elementPoint, Point(elementPoint.x, endY)) + } - SwipeDirection.RIGHT -> { - val endX = (deviceInfo.widthGrid * 0.9f).toInt() - directionalSwipe(durationMs, elementPoint, Point(endX, elementPoint.y)) - } + SwipeDirection.RIGHT -> { + val endX = (deviceInfo.widthGrid * 0.9f).toInt() + directionalSwipe(durationMs, elementPoint, Point(endX, elementPoint.y)) + } - SwipeDirection.LEFT -> { - val endX = (deviceInfo.widthGrid * 0.1f).toInt() - directionalSwipe(durationMs, elementPoint, Point(endX, elementPoint.y)) + SwipeDirection.LEFT -> { + val endX = (deviceInfo.widthGrid * 0.1f).toInt() + directionalSwipe(durationMs, elementPoint, Point(endX, elementPoint.y)) + } } } } private fun directionalSwipe(durationMs: Long, start: Point, end: Point) { - dadb.shell("input swipe ${start.x} ${start.y} ${end.x} ${end.y} $durationMs") + metrics.measured("operation", mapOf("command" to "directionalSwipe", "durationMs" to durationMs.toString())) { + dadb.shell("input swipe ${start.x} ${start.y} ${end.x} ${end.y} $durationMs") + } } override fun backPress() { - dadb.shell("input keyevent 4") - Thread.sleep(300) + metrics.measured("operation", mapOf("command" to "backPress")) { + dadb.shell("input keyevent 4") + Thread.sleep(300) + } } override fun hideKeyboard() { - dadb.shell("input keyevent 4") // 'Back', which dismisses the keyboard before handing over to navigation - Thread.sleep(300) - waitForAppToSettle(null, null) + metrics.measured("operation", mapOf("command" to "hideKeyboard")) { + dadb.shell("input keyevent 4") // 'Back', which dismisses the keyboard before handing over to navigation + Thread.sleep(300) + waitForAppToSettle(null, null) + } } override fun takeScreenshot(out: Sink, compressed: Boolean) { - runDeviceCall { - val response = blockingStubWithTimeout.screenshot(screenshotRequest {}) - out.buffer().use { - it.write(response.bytes.toByteArray()) + metrics.measured("operation", mapOf("command" to "takeScreenshot", "compressed" to compressed.toString())) { + runDeviceCall { + val response = blockingStubWithTimeout.screenshot(screenshotRequest {}) + out.buffer().use { + it.write(response.bytes.toByteArray()) + } } } } override fun startScreenRecording(out: Sink): ScreenRecording { - val deviceScreenRecordingPath = "/sdcard/maestro-screenrecording.mp4" - - val future = CompletableFuture.runAsync({ - val timeLimit = if (getDeviceApiLevel() >= 34) "--time-limit 0" else "" - try { - shell("screenrecord $timeLimit --bit-rate '100000' $deviceScreenRecordingPath") - } catch (e: IOException) { - throw IOException( - "Failed to capture screen recording on the device. Note that some Android emulators do not support screen recording. " + + return metrics.measured("operation", mapOf("command" to "startScreenRecording")) { + + val deviceScreenRecordingPath = "/sdcard/maestro-screenrecording.mp4" + + val future = CompletableFuture.runAsync({ + val timeLimit = if (getDeviceApiLevel() >= 34) "--time-limit 0" else "" + try { + shell("screenrecord $timeLimit --bit-rate '100000' $deviceScreenRecordingPath") + } catch (e: IOException) { + throw IOException( + "Failed to capture screen recording on the device. Note that some Android emulators do not support screen recording. " + "Try using a different Android emulator (eg. Pixel 5 / API 30)", - e, - ) - } - }, Executors.newSingleThreadExecutor()) - - return object : ScreenRecording { - override fun close() { - dadb.shell("killall -INT screenrecord") // Ignore exit code - future.get() - Thread.sleep(3000) - dadb.pull(out, deviceScreenRecordingPath) + e, + ) + } + }, Executors.newSingleThreadExecutor()) + + object : ScreenRecording { + override fun close() { + dadb.shell("killall -INT screenrecord") // Ignore exit code + future.get() + Thread.sleep(3000) + dadb.pull(out, deviceScreenRecordingPath) + } } } } override fun inputText(text: String) { - runDeviceCall { - blockingStubWithTimeout.inputText(inputTextRequest { - this.text = text - }) ?: throw IllegalStateException("Input Response can't be null") + metrics.measured("operation", mapOf("command" to "inputText")) { + runDeviceCall { + blockingStubWithTimeout.inputText(inputTextRequest { + this.text = text + }) ?: throw IllegalStateException("Input Response can't be null") + } } } override fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) { - if (browser) { - openBrowser(link) - } else { - dadb.shell("am start -a android.intent.action.VIEW -d \"$link\"") - } + metrics.measured("operation", mapOf("command" to "openLink", "appId" to appId, "autoVerify" to autoVerify.toString(), "browser" to browser.toString())) { + if (browser) { + openBrowser(link) + } else { + dadb.shell("am start -a android.intent.action.VIEW -d \"$link\"") + } - if (autoVerify) { - autoVerifyApp(appId) + if (autoVerify) { + autoVerifyApp(appId) + } } } @@ -589,54 +633,70 @@ class AndroidDriver( .map { parts: Array -> parts[1] } override fun setLocation(latitude: Double, longitude: Double) { - shell("appops set dev.mobile.maestro android:mock_location allow") + metrics.measured("operation", mapOf("command" to "setLocation")) { + shell("appops set dev.mobile.maestro android:mock_location allow") - runDeviceCall { - blockingStubWithTimeout.setLocation( - setLocationRequest { - this.latitude = latitude - this.longitude = longitude - } - ) ?: error("Set Location Response can't be null") + runDeviceCall { + blockingStubWithTimeout.setLocation( + setLocationRequest { + this.latitude = latitude + this.longitude = longitude + } + ) ?: error("Set Location Response can't be null") + } } } override fun eraseText(charactersToErase: Int) { - runDeviceCall { - blockingStubWithTimeout.eraseAllText( - eraseAllTextRequest { - this.charactersToErase = charactersToErase - } - ) ?: throw IllegalStateException("Erase Response can't be null") + metrics.measured("operation", mapOf("command" to "eraseText", "charactersToErase" to charactersToErase.toString())) { + runDeviceCall { + blockingStubWithTimeout.eraseAllText( + eraseAllTextRequest { + this.charactersToErase = charactersToErase + } + ) ?: throw IllegalStateException("Erase Response can't be null") + } } } override fun setProxy(host: String, port: Int) { - shell("""settings put global http_proxy "${host}:${port}"""") - proxySet = true + metrics.measured("operation", mapOf("command" to "setProxy")) { + shell("""settings put global http_proxy "${host}:${port}"""") + proxySet = true + } } override fun resetProxy() { - shell("settings put global http_proxy :0") + metrics.measured("operation", mapOf("command" to "resetProxy")) { + shell("settings put global http_proxy :0") + } } override fun isShutdown(): Boolean { - return channel.isShutdown + return metrics.measured("operation", mapOf("command" to "isShutdown")) { + channel.isShutdown + } } override fun isUnicodeInputSupported(): Boolean { return false } - override fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int?): ViewHierarchy { - return if (appId != null) { - waitForWindowToSettle(appId, initialHierarchy, timeoutMs) - } else { - ScreenshotUtils.waitForAppToSettle(initialHierarchy, this, timeoutMs) + override fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int?): ViewHierarchy? { + return metrics.measured("operation", mapOf("command" to "waitForAppToSettle", "appId" to appId, "timeoutMs" to timeoutMs.toString())) { + if (appId != null) { + waitForWindowToSettle(appId, initialHierarchy, timeoutMs) + } else { + ScreenshotUtils.waitForAppToSettle(initialHierarchy, this, timeoutMs) + } } } - private fun waitForWindowToSettle(appId: String, initialHierarchy: ViewHierarchy?, timeoutMs: Int? = null): ViewHierarchy { + private fun waitForWindowToSettle( + appId: String, + initialHierarchy: ViewHierarchy?, + timeoutMs: Int? = null + ): ViewHierarchy { val endTime = System.currentTimeMillis() + WINDOW_UPDATE_TIMEOUT_MS var hierarchy: ViewHierarchy? = null do { @@ -655,62 +715,75 @@ class AndroidDriver( } override fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean { - return ScreenshotUtils.waitUntilScreenIsStatic(timeoutMs, SCREENSHOT_DIFF_THRESHOLD, this) + return metrics.measured("operation", mapOf("command" to "waitUntilScreenIsStatic", "timeoutMs" to timeoutMs.toString())) { + ScreenshotUtils.waitUntilScreenIsStatic(timeoutMs, SCREENSHOT_DIFF_THRESHOLD, this) + } } override fun capabilities(): List { - return listOf( - Capability.FAST_HIERARCHY - ) + return metrics.measured("operation", mapOf("command" to "capabilities")) { + listOf( + Capability.FAST_HIERARCHY + ) + } } override fun setPermissions(appId: String, permissions: Map) { - val mutable = permissions.toMutableMap() - mutable.remove("all")?.let { value -> - setAllPermissions(appId, value) - } + metrics.measured("operation", mapOf("command" to "setPermissions", "appId" to appId)) { + val mutable = permissions.toMutableMap() + mutable.remove("all")?.let { value -> + setAllPermissions(appId, value) + } - mutable.forEach { permission -> - val permissionValue = translatePermissionValue(permission.value) - translatePermissionName(permission.key).forEach { permissionName -> - setPermissionInternal(appId, permissionName, permissionValue) + mutable.forEach { permission -> + val permissionValue = translatePermissionValue(permission.value) + translatePermissionName(permission.key).forEach { permissionName -> + setPermissionInternal(appId, permissionName, permissionValue) + } } } } override fun addMedia(mediaFiles: List) { - LOGGER.info("[Start] Adding media files") - mediaFiles.forEach { addMediaToDevice(it) } - LOGGER.info("[Done] Adding media files") + metrics.measured("operation", mapOf("command" to "addMedia", "mediaFilesCount" to mediaFiles.size.toString())) { + LOGGER.info("[Start] Adding media files") + mediaFiles.forEach { addMediaToDevice(it) } + LOGGER.info("[Done] Adding media files") + } } override fun isAirplaneModeEnabled(): Boolean { - return when (val result = shell("cmd connectivity airplane-mode").trim()) { - "No shell command implementation.", "" -> { - LOGGER.debug("Falling back to old airplane mode read method") - when (val fallbackResult = shell("settings get global airplane_mode_on").trim()) { - "0" -> false - "1" -> true - else -> throw IllegalStateException("Received invalid response from while trying to read airplane mode state: $fallbackResult") + return metrics.measured("operation", mapOf("command" to "isAirplaneModeEnabled")) { + when (val result = shell("cmd connectivity airplane-mode").trim()) { + "No shell command implementation.", "" -> { + LOGGER.debug("Falling back to old airplane mode read method") + when (val fallbackResult = shell("settings get global airplane_mode_on").trim()) { + "0" -> false + "1" -> true + else -> throw IllegalStateException("Received invalid response from while trying to read airplane mode state: $fallbackResult") + } } + + "disabled" -> false + "enabled" -> true + else -> throw IllegalStateException("Received invalid response while trying to read airplane mode state: $result") } - "disabled" -> false - "enabled" -> true - else -> throw IllegalStateException("Received invalid response while trying to read airplane mode state: $result") } } override fun setAirplaneMode(enabled: Boolean) { - // fallback to old way on API < 28 - if (getDeviceApiLevel() < 28) { - val num = if (enabled) 1 else 0 - shell("settings put global airplane_mode_on $num") - // We need to broadcast the change to really apply it - broadcastAirplaneMode(enabled) - return + metrics.measured("operation", mapOf("command" to "setAirplaneMode", "enabled" to enabled.toString())) { + // fallback to old way on API < 28 + if (getDeviceApiLevel() < 28) { + val num = if (enabled) 1 else 0 + shell("settings put global airplane_mode_on $num") + // We need to broadcast the change to really apply it + broadcastAirplaneMode(enabled) + return@measured + } + val value = if (enabled) "enable" else "disable" + shell("cmd connectivity airplane-mode $value") } - val value = if (enabled) "enable" else "disable" - shell("cmd connectivity airplane-mode $value") } private fun broadcastAirplaneMode(enabled: Boolean) { @@ -729,9 +802,12 @@ class AndroidDriver( } fun setDeviceLocale(country: String, language: String): Int { - dadb.shell("pm grant dev.mobile.maestro android.permission.CHANGE_CONFIGURATION") - val response = dadb.shell("am broadcast -a dev.mobile.maestro.locale -n dev.mobile.maestro/.receivers.LocaleSettingReceiver --es lang $language --es country $country") - return extractSetLocaleResult(response.output) + return metrics.measured("operation", mapOf("command" to "setDeviceLocale", "country" to country, "language" to language)) { + dadb.shell("pm grant dev.mobile.maestro android.permission.CHANGE_CONFIGURATION") + val response = + dadb.shell("am broadcast -a dev.mobile.maestro.locale -n dev.mobile.maestro/.receivers.LocaleSettingReceiver --es lang $language --es country $country") + extractSetLocaleResult(response.output) + } } private fun extractSetLocaleResult(result: String): Int { @@ -953,21 +1029,23 @@ class AndroidDriver( } fun installMaestroDriverApp() { - uninstallMaestroDriverApp() + metrics.measured("operation", mapOf("command" to "installMaestroDriverApp")) { + uninstallMaestroDriverApp() - val maestroAppApk = File.createTempFile("maestro-app", ".apk") + val maestroAppApk = File.createTempFile("maestro-app", ".apk") - Maestro::class.java.getResourceAsStream("/maestro-app.apk")?.let { - val bufferedSink = maestroAppApk.sink().buffer() - bufferedSink.writeAll(it.source()) - bufferedSink.flush() - } + Maestro::class.java.getResourceAsStream("/maestro-app.apk")?.let { + val bufferedSink = maestroAppApk.sink().buffer() + bufferedSink.writeAll(it.source()) + bufferedSink.flush() + } - install(maestroAppApk) - if (!isPackageInstalled("dev.mobile.maestro")) { - throw IllegalStateException("dev.mobile.maestro was not installed") + install(maestroAppApk) + if (!isPackageInstalled("dev.mobile.maestro")) { + throw IllegalStateException("dev.mobile.maestro was not installed") + } + maestroAppApk.delete() } - maestroAppApk.delete() } private fun installMaestroServerApp() { @@ -994,8 +1072,10 @@ class AndroidDriver( } fun uninstallMaestroDriverApp() { - if (isPackageInstalled("dev.mobile.maestro")) { - uninstall("dev.mobile.maestro") + metrics.measured("operation", mapOf("command" to "uninstallMaestroDriverApp")) { + if (isPackageInstalled("dev.mobile.maestro")) { + uninstall("dev.mobile.maestro") + } } } diff --git a/maestro-client/src/main/java/maestro/drivers/IOSDriver.kt b/maestro-client/src/main/java/maestro/drivers/IOSDriver.kt index b3b67ef4ad..cb4a71bde5 100644 --- a/maestro-client/src/main/java/maestro/drivers/IOSDriver.kt +++ b/maestro-client/src/main/java/maestro/drivers/IOSDriver.kt @@ -40,30 +40,41 @@ import kotlin.collections.set class IOSDriver( private val iosDevice: IOSDevice, - private val insights: Insights = NoopInsights -) : Driver { + private val insights: Insights = NoopInsights, + private val metricsProvider: Metrics = MetricsProvider.getInstance(), + ) : Driver { + + private val metrics = metricsProvider.withPrefix("maestro.driver").withTags(mapOf("platform" to "ios", "deviceId" to iosDevice.deviceId).filterValues { it != null }.mapValues { it.value!! }) private var appId: String? = null private var proxySet = false override fun name(): String { - return NAME + return metrics.measured("name") { + NAME + } } override fun open() { - iosDevice.open() + metrics.measured("open") { + iosDevice.open() + } } override fun close() { - if (proxySet) { - resetProxy() + metrics.measured("close") { + if (proxySet) { + resetProxy() + } + iosDevice.close() + appId = null } - iosDevice.close() - appId = null } override fun deviceInfo(): DeviceInfo { - return runDeviceCall("deviceInfo") { iosDevice.deviceInfo().toCommonDeviceInfo() } + return metrics.measured("operation", mapOf("command" to "deviceInfo")) { + runDeviceCall("deviceInfo") { iosDevice.deviceInfo().toCommonDeviceInfo() } + } } override fun launchApp( @@ -71,62 +82,80 @@ class IOSDriver( launchArguments: Map, sessionId: UUID?, ) { - iosDevice.launch(appId, launchArguments, sessionId) - .onSuccess { this.appId = appId } - .getOrThrow { - MaestroException.UnableToLaunchApp("Unable to launch app $appId ${it.message}") - } + metrics.measured("operation", mapOf("command" to "launchApp", "appId" to appId)) { + iosDevice.launch(appId, launchArguments, sessionId) + .onSuccess { this.appId = appId } + .getOrThrow { + MaestroException.UnableToLaunchApp("Unable to launch app $appId ${it.message}") + } + } } override fun stopApp(appId: String) { - iosDevice.stop(appId) + metrics.measured("operation", mapOf("command" to "stopApp", "appId" to appId)) { + iosDevice.stop(appId) + } } override fun killApp(appId: String) { - // On iOS there is no Process Death like on Android so this command will be a synonym to the stop command - stopApp(appId) + metrics.measured("operation", mapOf("command" to "killApp", "appId" to appId)) { + // On iOS there is no Process Death like on Android so this command will be a synonym to the stop command + stopApp(appId) + } } override fun clearAppState(appId: String) { - iosDevice.clearAppState(appId) + metrics.measured("operation", mapOf("command" to "clearAppState", "appId" to appId)) { + iosDevice.clearAppState(appId) + } } override fun clearKeychain() { - iosDevice.clearKeychain().expect {} + metrics.measured("operation", mapOf("command" to "clearKeychain")) { + iosDevice.clearKeychain().expect {} + } } override fun tap(point: Point) { - runDeviceCall("tap") { iosDevice.tap(point.x, point.y) } + metrics.measured("operation", mapOf("command" to "tap")) { + runDeviceCall("tap") { iosDevice.tap(point.x, point.y) } + } } override fun longPress(point: Point) { - runDeviceCall("longPress") { iosDevice.longPress(point.x, point.y, 3000) } + metrics.measured("operation", mapOf("command" to "longPress")) { + runDeviceCall("longPress") { iosDevice.longPress(point.x, point.y, 3000) } + } } override fun pressKey(code: KeyCode) { - val keyCodeNameMap = mapOf( - KeyCode.BACKSPACE to "delete", - KeyCode.ENTER to "return", - ) + metrics.measured("operation", mapOf("command" to "pressKey")) { + val keyCodeNameMap = mapOf( + KeyCode.BACKSPACE to "delete", + KeyCode.ENTER to "return", + ) - val buttonNameMap = mapOf( - KeyCode.HOME to "home", - KeyCode.LOCK to "lock", - ) + val buttonNameMap = mapOf( + KeyCode.HOME to "home", + KeyCode.LOCK to "lock", + ) - runDeviceCall("pressKey") { - keyCodeNameMap[code]?.let { name -> - iosDevice.pressKey(name) - } + runDeviceCall("pressKey") { + keyCodeNameMap[code]?.let { name -> + iosDevice.pressKey(name) + } - buttonNameMap[code]?.let { name -> - iosDevice.pressButton(name) + buttonNameMap[code]?.let { name -> + iosDevice.pressButton(name) + } } } } override fun contentDescriptor(excludeKeyboardElements: Boolean): TreeNode { - return runDeviceCall("contentDescriptor") { viewHierarchy(excludeKeyboardElements) } + return metrics.measured("operation", mapOf("command" to "contentDescriptor")) { + runDeviceCall("contentDescriptor") { viewHierarchy(excludeKeyboardElements) } + } } private fun viewHierarchy(excludeKeyboardElements: Boolean): TreeNode { @@ -194,7 +223,9 @@ class IOSDriver( } override fun isKeyboardVisible(): Boolean { - return runDeviceCall("isKeyboardVisible") { iosDevice.isKeyboardVisible() } + return metrics.measured("operation", mapOf("command" to "isKeyboardVisible")) { + runDeviceCall("isKeyboardVisible") { iosDevice.isKeyboardVisible() } + } } override fun swipe( @@ -202,102 +233,108 @@ class IOSDriver( end: Point, durationMs: Long ) { - val deviceInfo = deviceInfo() - val startPoint = start.coerceIn(maxWidth = deviceInfo.widthGrid, maxHeight = deviceInfo.heightGrid) - val endPoint = end.coerceIn(maxWidth = deviceInfo.widthGrid, maxHeight = deviceInfo.heightGrid) - - runDeviceCall("swipe") { - waitForAppToSettle(null, null) - iosDevice.scroll( - xStart = startPoint.x.toDouble(), - yStart = startPoint.y.toDouble(), - xEnd = endPoint.x.toDouble(), - yEnd = endPoint.y.toDouble(), - duration = durationMs.toDouble() / 1000 - ) + metrics.measured("operation", mapOf("command" to "swipe", "durationMs" to durationMs.toString())) { + val deviceInfo = deviceInfo() + val startPoint = start.coerceIn(maxWidth = deviceInfo.widthGrid, maxHeight = deviceInfo.heightGrid) + val endPoint = end.coerceIn(maxWidth = deviceInfo.widthGrid, maxHeight = deviceInfo.heightGrid) + + runDeviceCall("swipe") { + waitForAppToSettle(null, null) + iosDevice.scroll( + xStart = startPoint.x.toDouble(), + yStart = startPoint.y.toDouble(), + xEnd = endPoint.x.toDouble(), + yEnd = endPoint.y.toDouble(), + duration = durationMs.toDouble() / 1000 + ) + } } } override fun swipe(swipeDirection: SwipeDirection, durationMs: Long) { - val deviceInfo = deviceInfo() - val width = deviceInfo.widthGrid - val height = deviceInfo.heightGrid - - val startPoint: Point - val endPoint: Point - - when (swipeDirection) { - SwipeDirection.UP -> { - startPoint = Point( - x = 0.5.asPercentOf(width), - y = 0.9.asPercentOf(height), - ) - endPoint = Point( - x = 0.5.asPercentOf(width), - y = 0.1.asPercentOf(height), - ) - } + metrics.measured("operation", mapOf("command" to "swipeWithDirection", "direction" to swipeDirection.name, "durationMs" to durationMs.toString())) { + val deviceInfo = deviceInfo() + val width = deviceInfo.widthGrid + val height = deviceInfo.heightGrid + + val startPoint: Point + val endPoint: Point + + when (swipeDirection) { + SwipeDirection.UP -> { + startPoint = Point( + x = 0.5.asPercentOf(width), + y = 0.9.asPercentOf(height), + ) + endPoint = Point( + x = 0.5.asPercentOf(width), + y = 0.1.asPercentOf(height), + ) + } - SwipeDirection.DOWN -> { - startPoint = Point( - x = 0.5.asPercentOf(width), - y = 0.2.asPercentOf(height), - ) - endPoint = Point( - x = 0.5.asPercentOf(width), - y = 0.9.asPercentOf(height), - ) - } + SwipeDirection.DOWN -> { + startPoint = Point( + x = 0.5.asPercentOf(width), + y = 0.2.asPercentOf(height), + ) + endPoint = Point( + x = 0.5.asPercentOf(width), + y = 0.9.asPercentOf(height), + ) + } - SwipeDirection.RIGHT -> { - startPoint = Point( - x = 0.1.asPercentOf(width), - y = 0.5.asPercentOf(height), - ) - endPoint = Point( - x = 0.9.asPercentOf(width), - y = 0.5.asPercentOf(height), - ) - } + SwipeDirection.RIGHT -> { + startPoint = Point( + x = 0.1.asPercentOf(width), + y = 0.5.asPercentOf(height), + ) + endPoint = Point( + x = 0.9.asPercentOf(width), + y = 0.5.asPercentOf(height), + ) + } - SwipeDirection.LEFT -> { - startPoint = Point( - x = 0.9.asPercentOf(width), - y = 0.5.asPercentOf(height), - ) - endPoint = Point( - x = 0.1.asPercentOf(width), - y = 0.5.asPercentOf(height), - ) + SwipeDirection.LEFT -> { + startPoint = Point( + x = 0.9.asPercentOf(width), + y = 0.5.asPercentOf(height), + ) + endPoint = Point( + x = 0.1.asPercentOf(width), + y = 0.5.asPercentOf(height), + ) + } } + swipe(startPoint, endPoint, durationMs) } - swipe(startPoint, endPoint, durationMs) } override fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long) { - val deviceInfo = deviceInfo() - val width = deviceInfo.widthGrid - val height = deviceInfo.heightGrid - - when (direction) { - SwipeDirection.UP -> { - val end = Point(x = elementPoint.x, y = 0.1.asPercentOf(height)) - swipe(elementPoint, end, durationMs) - } + metrics.measured("operation", mapOf("command" to "swipeWithElementPoint", "direction" to direction.name, "durationMs" to durationMs.toString())) { + val deviceInfo = deviceInfo() + val width = deviceInfo.widthGrid + val height = deviceInfo.heightGrid + + when (direction) { + SwipeDirection.UP -> { + val end = Point(x = elementPoint.x, y = 0.1.asPercentOf(height)) + swipe(elementPoint, end, durationMs) + } - SwipeDirection.DOWN -> { - val end = Point(x = elementPoint.x, y = 0.9.asPercentOf(height)) - swipe(elementPoint, end, durationMs) - } + SwipeDirection.DOWN -> { + val end = Point(x = elementPoint.x, y = 0.9.asPercentOf(height)) + swipe(elementPoint, end, durationMs) + } - SwipeDirection.RIGHT -> { - val end = Point(x = (0.9).asPercentOf(width), y = elementPoint.y) - swipe(elementPoint, end, durationMs) - } + SwipeDirection.RIGHT -> { + val end = Point(x = (0.9).asPercentOf(width), y = elementPoint.y) + swipe(elementPoint, end, durationMs) + } - SwipeDirection.LEFT -> { - val end = Point(x = (0.1).asPercentOf(width), y = elementPoint.y) - swipe(elementPoint, end, durationMs) + SwipeDirection.LEFT -> { + val end = Point(x = (0.1).asPercentOf(width), y = elementPoint.y) + swipe(elementPoint, end, durationMs) + } } } } @@ -305,29 +342,31 @@ class IOSDriver( override fun backPress() {} override fun hideKeyboard() { - val deviceInfo = deviceInfo() - val width = deviceInfo.widthGrid - val height = deviceInfo.heightGrid + metrics.measured("operation", mapOf("command" to "hideKeyboard")) { + val deviceInfo = deviceInfo() + val width = deviceInfo.widthGrid + val height = deviceInfo.heightGrid - dismissKeyboardIntroduction(heightPoints = deviceInfo.heightGrid) + dismissKeyboardIntroduction(heightPoints = deviceInfo.heightGrid) - if (isKeyboardHidden()) return + if (isKeyboardHidden()) return@measured - swipe( - start = Point(0.5.asPercentOf(width), 0.5.asPercentOf(height)), - end = Point(0.5.asPercentOf(width), 0.47.asPercentOf(height)), - durationMs = 50, - ) + swipe( + start = Point(0.5.asPercentOf(width), 0.5.asPercentOf(height)), + end = Point(0.5.asPercentOf(width), 0.47.asPercentOf(height)), + durationMs = 50, + ) - if (isKeyboardHidden()) return + if (isKeyboardHidden()) return@measured - swipe( - start = Point(0.5.asPercentOf(width), 0.5.asPercentOf(height)), - end = Point(0.47.asPercentOf(width), 0.5.asPercentOf(height)), - durationMs = 50, - ) + swipe( + start = Point(0.5.asPercentOf(width), 0.5.asPercentOf(height)), + end = Point(0.47.asPercentOf(width), 0.5.asPercentOf(height)), + durationMs = 50, + ) - waitForAppToSettle(null, null) + waitForAppToSettle(null, null) + } } private fun isKeyboardHidden(): Boolean { @@ -360,36 +399,50 @@ class IOSDriver( } override fun takeScreenshot(out: Sink, compressed: Boolean) { - runDeviceCall("takeScreenshot") { iosDevice.takeScreenshot(out, compressed) } + metrics.measured("operation", mapOf("command" to "takeScreenshot")) { + runDeviceCall("takeScreenshot") { iosDevice.takeScreenshot(out, compressed) } + } } override fun startScreenRecording(out: Sink): ScreenRecording { - val iosScreenRecording = iosDevice.startScreenRecording(out).expect {} - return object : ScreenRecording { - override fun close() = iosScreenRecording.close() + return metrics.measured("operation", mapOf("command" to "startScreenRecording")) { + val iosScreenRecording = iosDevice.startScreenRecording(out).expect {} + object : ScreenRecording { + override fun close() = iosScreenRecording.close() + } } } override fun inputText(text: String) { - // silently fail if no XCUIElement has focus - runDeviceCall("inputText") { iosDevice.input(text = text) } + metrics.measured("operation", mapOf("command" to "inputText")) { + // silently fail if no XCUIElement has focus + runDeviceCall("inputText") { iosDevice.input(text = text) } + } } override fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) { - iosDevice.openLink(link).expect {} + metrics.measured("operation", mapOf("command" to "openLink", "appId" to appId.toString(), "autoVerify" to autoVerify.toString(), "browser" to browser.toString())) { + iosDevice.openLink(link).expect {} + } } override fun setLocation(latitude: Double, longitude: Double) { - iosDevice.setLocation(latitude, longitude).expect {} + metrics.measured("operation", mapOf("command" to "setLocation")) { + runDeviceCall("setLocation") { iosDevice.setLocation(latitude, longitude).expect {} } + } } override fun eraseText(charactersToErase: Int) { - runDeviceCall("eraseText") { iosDevice.eraseText(charactersToErase) } + metrics.measured("operation", mapOf("command" to "eraseText")) { + runDeviceCall("eraseText") { iosDevice.eraseText(charactersToErase) } + } } override fun setProxy(host: String, port: Int) { - XCRunnerCLIUtils.setProxy(host, port) - proxySet = true + metrics.measured("operation", mapOf("command" to "setProxy")) { + XCRunnerCLIUtils.setProxy(host, port) + proxySet = true + } } override fun resetProxy() { @@ -397,23 +450,29 @@ class IOSDriver( } override fun isShutdown(): Boolean { - return iosDevice.isShutdown() + return metrics.measured("operation", mapOf("command" to "isShutdown")) { + iosDevice.isShutdown() + } } override fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean { - return MaestroTimer.retryUntilTrue(timeoutMs) { - val isScreenStatic = isScreenStatic() + return metrics.measured("operation", mapOf("command" to "waitUntilScreenIsStatic", "timeoutMs" to timeoutMs.toString())) { + MaestroTimer.retryUntilTrue(timeoutMs) { + val isScreenStatic = isScreenStatic() - LOGGER.info("screen static = $isScreenStatic") - return@retryUntilTrue isScreenStatic + LOGGER.info("screen static = $isScreenStatic") + return@retryUntilTrue isScreenStatic + } } } override fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int?): ViewHierarchy? { - LOGGER.info("Waiting for animation to end with timeout $SCREEN_SETTLE_TIMEOUT_MS") - val didFinishOnTime = waitUntilScreenIsStatic(SCREEN_SETTLE_TIMEOUT_MS) + return metrics.measured("operation", mapOf("command" to "waitForAppToSettle", "appId" to appId.toString(), "timeoutMs" to timeoutMs.toString())) { + LOGGER.info("Waiting for animation to end with timeout $SCREEN_SETTLE_TIMEOUT_MS") + val didFinishOnTime = waitUntilScreenIsStatic(SCREEN_SETTLE_TIMEOUT_MS) - return if (didFinishOnTime) null else ScreenshotUtils.waitForAppToSettle(initialHierarchy, this, timeoutMs) + if (didFinishOnTime) null else ScreenshotUtils.waitForAppToSettle(initialHierarchy, this, timeoutMs) + } } override fun capabilities(): List { @@ -421,15 +480,19 @@ class IOSDriver( } override fun setPermissions(appId: String, permissions: Map) { - runDeviceCall("setPermissions") { - iosDevice.setPermissions(appId, permissions) + metrics.measured("operation", mapOf("command" to "setPermissions", "appId" to appId)) { + runDeviceCall("setPermissions") { + iosDevice.setPermissions(appId, permissions) + } } } override fun addMedia(mediaFiles: List) { - LOGGER.info("[Start] Adding media files") - mediaFiles.forEach { addMediaToDevice(it) } - LOGGER.info("[Done] Adding media files") + metrics.measured("operation", mapOf("command" to "addMedia", "mediaFilesCount" to mediaFiles.size.toString())) { + LOGGER.info("[Start] Adding media files") + mediaFiles.forEach { addMediaToDevice(it) } + LOGGER.info("[Done] Adding media files") + } } override fun isAirplaneModeEnabled(): Boolean { @@ -442,17 +505,19 @@ class IOSDriver( } private fun addMediaToDevice(mediaFile: File) { - val namedSource = NamedSource( - mediaFile.name, - mediaFile.source(), - mediaFile.extension, - mediaFile.path - ) - MediaExt.values().firstOrNull { mediaExt -> mediaExt.extName == namedSource.extension } - ?: throw IllegalArgumentException( - "Extension .${namedSource.extension} is not yet supported for add media" + metrics.measured("operation", mapOf("command" to "addMediaToDevice")) { + val namedSource = NamedSource( + mediaFile.name, + mediaFile.source(), + mediaFile.extension, + mediaFile.path ) - iosDevice.addMedia(namedSource.path) + MediaExt.values().firstOrNull { mediaExt -> mediaExt.extName == namedSource.extension } + ?: throw IllegalArgumentException( + "Extension .${namedSource.extension} is not yet supported for add media" + ) + iosDevice.addMedia(namedSource.path) + } } private fun isScreenStatic(): Boolean { diff --git a/maestro-client/src/main/java/maestro/js/GraalJsEngine.kt b/maestro-client/src/main/java/maestro/js/GraalJsEngine.kt index d23bce860d..8b86422fe2 100644 --- a/maestro-client/src/main/java/maestro/js/GraalJsEngine.kt +++ b/maestro-client/src/main/java/maestro/js/GraalJsEngine.kt @@ -1,5 +1,6 @@ package maestro.js +import maestro.utils.HttpClient import okhttp3.OkHttpClient import okhttp3.Protocol import org.graalvm.polyglot.Context @@ -10,6 +11,7 @@ import java.io.ByteArrayOutputStream import java.util.concurrent.TimeUnit import java.util.logging.Handler import java.util.logging.LogRecord +import kotlin.time.Duration.Companion.minutes private val NULL_HANDLER = object : Handler() { override fun publish(record: LogRecord?) {} @@ -20,11 +22,12 @@ private val NULL_HANDLER = object : Handler() { } class GraalJsEngine( - httpClient: OkHttpClient = OkHttpClient.Builder() - .readTimeout(5, TimeUnit.MINUTES) - .writeTimeout(5, TimeUnit.MINUTES) - .protocols(listOf(Protocol.HTTP_1_1)) - .build(), + httpClient: OkHttpClient = HttpClient.build( + name = "GraalJsEngine", + readTimeout = 5.minutes, + writeTimeout = 5.minutes, + protocols = listOf(Protocol.HTTP_1_1) + ), platform: String = "unknown", ) : JsEngine { diff --git a/maestro-client/src/main/java/maestro/js/RhinoJsEngine.kt b/maestro-client/src/main/java/maestro/js/RhinoJsEngine.kt index c8d109ab5a..55e6db6d1b 100644 --- a/maestro-client/src/main/java/maestro/js/RhinoJsEngine.kt +++ b/maestro-client/src/main/java/maestro/js/RhinoJsEngine.kt @@ -1,17 +1,19 @@ package maestro.js +import maestro.utils.HttpClient import okhttp3.OkHttpClient import okhttp3.Protocol import org.mozilla.javascript.Context import org.mozilla.javascript.ScriptableObject -import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.minutes class RhinoJsEngine( - httpClient: OkHttpClient = OkHttpClient.Builder() - .readTimeout(5, TimeUnit.MINUTES) - .writeTimeout(5, TimeUnit.MINUTES) - .protocols(listOf(Protocol.HTTP_1_1)) - .build(), + httpClient: OkHttpClient = HttpClient.build( + name="RhinoJsEngine", + readTimeout=5.minutes, + writeTimeout=5.minutes, + protocols=listOf(Protocol.HTTP_1_1) + ), platform: String = "unknown", ) : JsEngine { @@ -116,4 +118,4 @@ class RhinoJsEngine( ) } -} \ No newline at end of file +} diff --git a/maestro-client/src/main/java/maestro/mockserver/MockInteractor.kt b/maestro-client/src/main/java/maestro/mockserver/MockInteractor.kt index a70134d9ee..0f41dcb370 100644 --- a/maestro-client/src/main/java/maestro/mockserver/MockInteractor.kt +++ b/maestro-client/src/main/java/maestro/mockserver/MockInteractor.kt @@ -2,7 +2,7 @@ package maestro.mockserver import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import okhttp3.OkHttpClient +import maestro.utils.HttpClient import okhttp3.Protocol import okhttp3.Request import java.nio.file.Paths @@ -12,6 +12,7 @@ import kotlin.io.path.exists import kotlin.io.path.isDirectory import kotlin.io.path.readText import kotlin.math.max +import kotlin.time.Duration.Companion.minutes data class Auth( val teamId: UUID, @@ -39,11 +40,12 @@ data class GetEventsResponse( ) class MockInteractor { - private val client = OkHttpClient.Builder() - .readTimeout(5, TimeUnit.MINUTES) - .writeTimeout(5, TimeUnit.MINUTES) - .protocols(listOf(Protocol.HTTP_1_1)) - .build() + private val client = HttpClient.build( + name = "MockInteractor", + readTimeout = 5.minutes, + writeTimeout = 5.minutes, + protocols = listOf(Protocol.HTTP_1_1) + ) fun getCachedAuthToken(): String? { if (!System.getProperty("MAESTRO_CLOUD_API_KEY").isNullOrEmpty()) return System.getProperty("MAESTRO_CLOUD_API_KEY") diff --git a/maestro-client/src/test/java/maestro/xctestdriver/XCTestDriverClientTest.kt b/maestro-client/src/test/java/maestro/xctestdriver/XCTestDriverClientTest.kt index 513dd5e682..41d9505f54 100644 --- a/maestro-client/src/test/java/maestro/xctestdriver/XCTestDriverClientTest.kt +++ b/maestro-client/src/test/java/maestro/xctestdriver/XCTestDriverClientTest.kt @@ -30,7 +30,7 @@ class XCTestDriverClientTest { setBody(mapper.writeValueAsString(error)) } mockWebServer.enqueue(mockResponse) - mockWebServer.start(InetAddress.getByName( "localhost"), 22087) + mockWebServer.start(InetAddress.getByName("localhost"), 22087) val httpUrl = mockWebServer.url("/deviceInfo") // when @@ -61,7 +61,7 @@ class XCTestDriverClientTest { setBody(mapper.writeValueAsString(expectedDeviceInfo)) } mockWebServer.enqueue(mockResponse) - mockWebServer.start(InetAddress.getByName( "localhost"), 22087) + mockWebServer.start(InetAddress.getByName("localhost"), 22087) val httpUrl = mockWebServer.url("/deviceInfo") // when diff --git a/maestro-ios-driver/src/main/kotlin/xcuitest/XCTestDriverClient.kt b/maestro-ios-driver/src/main/kotlin/xcuitest/XCTestDriverClient.kt index ceb555fa27..b1c7769eb3 100644 --- a/maestro-ios-driver/src/main/kotlin/xcuitest/XCTestDriverClient.kt +++ b/maestro-ios-driver/src/main/kotlin/xcuitest/XCTestDriverClient.kt @@ -2,23 +2,24 @@ package xcuitest import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import hierarchy.ViewHierarchy +import maestro.utils.HttpClient import maestro.utils.network.XCUITestServerError import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.logging.HttpLoggingInterceptor import org.slf4j.LoggerFactory import xcuitest.api.* import xcuitest.installer.XCTestInstaller import java.io.IOException -import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds class XCTestDriverClient( private val installer: XCTestInstaller, - private val okHttpClient: OkHttpClient = OkHttpClient.Builder() - .connectTimeout(1, TimeUnit.SECONDS) - .readTimeout(200, TimeUnit.SECONDS) - .build() + private val okHttpClient: OkHttpClient = HttpClient.build( + name = "XCTestDriverClient", + readTimeout = 200.seconds, + connectTimeout = 1.seconds + ) ) { private val logger = LoggerFactory.getLogger(XCTestDriverClient::class.java) diff --git a/maestro-ios-driver/src/main/kotlin/xcuitest/installer/LocalXCTestInstaller.kt b/maestro-ios-driver/src/main/kotlin/xcuitest/installer/LocalXCTestInstaller.kt index 0d7296cd50..b8e459df95 100644 --- a/maestro-ios-driver/src/main/kotlin/xcuitest/installer/LocalXCTestInstaller.kt +++ b/maestro-ios-driver/src/main/kotlin/xcuitest/installer/LocalXCTestInstaller.kt @@ -1,6 +1,9 @@ package xcuitest.installer +import maestro.utils.HttpClient import maestro.utils.MaestroTimer +import maestro.utils.Metrics +import maestro.utils.MetricsProvider import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.Request @@ -14,20 +17,23 @@ import util.XCRunnerCLIUtils import xcuitest.XCTestClient import java.io.File import java.io.IOException -import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds class LocalXCTestInstaller( private val deviceId: String, private val host: String = "[::1]", private val enableXCTestOutputFileLogging: Boolean, private val defaultPort: Int, - private val okHttpClient: OkHttpClient = OkHttpClient.Builder() - .connectTimeout(1, TimeUnit.SECONDS) - .readTimeout(100, TimeUnit.SECONDS) - .build() -) : XCTestInstaller { + private val metricsProvider: Metrics = MetricsProvider.getInstance(), + private val httpClient: OkHttpClient = HttpClient.build( + name = "XCUITestDriverStatusCheck", + connectTimeout = 1.seconds, + readTimeout = 100.seconds, + ), + ) : XCTestInstaller { private val logger = LoggerFactory.getLogger(LocalXCTestInstaller::class.java) + private val metrics = metricsProvider.withPrefix("xcuitest.installer").withTags(mapOf("kind" to "local", "deviceId" to deviceId, "host" to host)) /** * If true, allow for using a xctest runner started from Xcode. @@ -41,73 +47,77 @@ class LocalXCTestInstaller( private var xcTestProcess: Process? = null override fun uninstall(): Boolean { - // FIXME(bartekpacia): This method probably doesn't have to care about killing the XCTest Runner process. - // Just uninstalling should suffice. It automatically kills the process. + return metrics.measured("operation", mapOf("command" to "uninstall")) { + // FIXME(bartekpacia): This method probably doesn't have to care about killing the XCTest Runner process. + // Just uninstalling should suffice. It automatically kills the process. - if (useXcodeTestRunner) { - logger.trace("Skipping uninstalling XCTest Runner as USE_XCODE_TEST_RUNNER is set") - return false - } + if (useXcodeTestRunner) { + logger.trace("Skipping uninstalling XCTest Runner as USE_XCODE_TEST_RUNNER is set") + return@measured false + } - if (!isChannelAlive()) return false + if (!isChannelAlive()) return@measured false - fun killXCTestRunnerProcess() { - logger.trace("Will attempt to stop all alive XCTest Runner processes before uninstalling") + fun killXCTestRunnerProcess() { + logger.trace("Will attempt to stop all alive XCTest Runner processes before uninstalling") - if (xcTestProcess?.isAlive == true) { - logger.trace("XCTest Runner process started by us is alive, killing it") - xcTestProcess?.destroy() - } - xcTestProcess = null - - val pid = XCRunnerCLIUtils.pidForApp(UI_TEST_RUNNER_APP_BUNDLE_ID, deviceId) - if (pid != null) { - logger.trace("Killing XCTest Runner process with the `kill` command") - ProcessBuilder(listOf("kill", pid.toString())) - .start() - .waitFor() - } + if (xcTestProcess?.isAlive == true) { + logger.trace("XCTest Runner process started by us is alive, killing it") + xcTestProcess?.destroy() + } + xcTestProcess = null + + val pid = XCRunnerCLIUtils.pidForApp(UI_TEST_RUNNER_APP_BUNDLE_ID, deviceId) + if (pid != null) { + logger.trace("Killing XCTest Runner process with the `kill` command") + ProcessBuilder(listOf("kill", pid.toString())) + .start() + .waitFor() + } - logger.trace("All XCTest Runner processes were stopped") - } + logger.trace("All XCTest Runner processes were stopped") + } - killXCTestRunnerProcess() + killXCTestRunnerProcess() - logger.trace("Uninstalling XCTest Runner from device $deviceId") - return true + logger.trace("Uninstalling XCTest Runner from device $deviceId") + true + } } override fun start(): XCTestClient? { - logger.info("start()") - - if (useXcodeTestRunner) { - logger.info("USE_XCODE_TEST_RUNNER is set. Will wait for XCTest runner to be started manually") - - repeat(20) { - if (ensureOpen()) { - return XCTestClient(host, defaultPort) + return metrics.measured("operation", mapOf("command" to "start")) { + logger.info("start()") + + if (useXcodeTestRunner) { + logger.info("USE_XCODE_TEST_RUNNER is set. Will wait for XCTest runner to be started manually") + + repeat(20) { + if (ensureOpen()) { + return@measured XCTestClient(host, defaultPort) + } + logger.info("==> Start XCTest runner to continue flow") + Thread.sleep(500) } - logger.info("==> Start XCTest runner to continue flow") - Thread.sleep(500) + throw IllegalStateException("XCTest was not started manually") } - throw IllegalStateException("XCTest was not started manually") - } - logger.info("[Start] Install XCUITest runner on $deviceId") - startXCTestRunner() - logger.info("[Done] Install XCUITest runner on $deviceId") + logger.info("[Start] Install XCUITest runner on $deviceId") + startXCTestRunner() + logger.info("[Done] Install XCUITest runner on $deviceId") - val startTime = System.currentTimeMillis() + val startTime = System.currentTimeMillis() - while (System.currentTimeMillis() - startTime < getStartupTimeout()) { - runCatching { - if (isChannelAlive()) return XCTestClient(host, defaultPort) + while (System.currentTimeMillis() - startTime < getStartupTimeout()) { + runCatching { + if (isChannelAlive()) return@measured XCTestClient(host, defaultPort) + } + Thread.sleep(500) } - Thread.sleep(500) - } - throw IOSDriverTimeoutException("iOS driver not ready in time, consider increasing timeout by configuring MAESTRO_DRIVER_STARTUP_TIMEOUT env variable") + throw IOSDriverTimeoutException("iOS driver not ready in time, consider increasing timeout by configuring MAESTRO_DRIVER_STARTUP_TIMEOUT env variable") + } } class IOSDriverTimeoutException(message: String): RuntimeException(message) @@ -117,7 +127,9 @@ class LocalXCTestInstaller( }.getOrDefault(SERVER_LAUNCH_TIMEOUT_MS) override fun isChannelAlive(): Boolean { - return xcTestDriverStatusCheck() + return metrics.measured("operation", mapOf("command" to "isChannelAlive")) { + return@measured xcTestDriverStatusCheck() + } } private fun ensureOpen(): Boolean { @@ -144,15 +156,15 @@ class LocalXCTestInstaller( xctestAPIBuilder("status") .build() } - val request by lazy { - Request.Builder() - .get() - .url(url) - .build() + + val request by lazy { Request.Builder() + .get() + .url(url) + .build() } val checkSuccessful = try { - okHttpClient.newCall(request).execute().use { + httpClient.newCall(request).execute().use { logger.info("[Done] Perform XCUITest driver status check on $deviceId") it.isSuccessful } diff --git a/maestro-utils/build.gradle.kts b/maestro-utils/build.gradle.kts index fea9bf58cf..c9b828813e 100644 --- a/maestro-utils/build.gradle.kts +++ b/maestro-utils/build.gradle.kts @@ -9,6 +9,9 @@ plugins { dependencies { api(libs.square.okio) + implementation(libs.square.okhttp) + implementation(libs.micrometer.core) + implementation(libs.micrometer.observation) testImplementation(libs.mockk) testImplementation(libs.junit.jupiter.api) diff --git a/maestro-utils/src/main/kotlin/HttpClient.kt b/maestro-utils/src/main/kotlin/HttpClient.kt new file mode 100644 index 0000000000..58555027d8 --- /dev/null +++ b/maestro-utils/src/main/kotlin/HttpClient.kt @@ -0,0 +1,100 @@ +package maestro.utils + +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import okhttp3.Call +import okhttp3.EventListener +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Protocol +import java.net.InetSocketAddress +import java.net.Proxy + +class MetricsEventListener( + private val registry: Metrics, + private val clientName: String, +) : EventListener() { + + override fun connectFailed( + call: Call, + inetSocketAddress: InetSocketAddress, + proxy: Proxy, + protocol: Protocol?, + ioe: IOException + ) { + registry.counter( + "http.client.errors", + mapOf( + "client" to clientName, + "method" to call.request().method, + "url" to call.request().url.host, + "exception" to ioe.javaClass.simpleName, + "kind" to "connect" + ) + ).increment() + } + + override fun callFailed(call: Call, ioe: IOException) { + registry.counter( + "http.client.errors", + mapOf( + "client" to clientName, + "method" to call.request().method, + "url" to call.request().url.host, + "exception" to ioe.javaClass.simpleName, + "kind" to "call" + ) + ).increment() + } + + class Factory( + private val registry: Metrics, + private val clientName: String, + ) : EventListener.Factory { + override fun create(call: Call): EventListener = + MetricsEventListener(registry, clientName) + } +} + +// utility object to build http clients with metrics +object HttpClient { + fun build( + name: String, + connectTimeout: Duration = 10.seconds, + readTimeout: Duration = 10.seconds, + writeTimeout: Duration = 10.seconds, + interceptors: List = emptyList(), + networkInterceptors: List = emptyList(), + protocols: List = listOf(Protocol.HTTP_1_1), + metrics: Metrics = MetricsProvider.getInstance() + ): OkHttpClient { + var b = OkHttpClient.Builder() + .eventListenerFactory(MetricsEventListener.Factory(metrics, name)) + .connectTimeout(connectTimeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) + .readTimeout(readTimeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) + .writeTimeout(writeTimeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) + .addNetworkInterceptor(Interceptor { chain -> + val start = System.currentTimeMillis() + val response = chain.proceed(chain.request()) + val duration = System.currentTimeMillis() - start + metrics.timer( + "http.client.request.duration", + mapOf( + "client" to name, + "method" to chain.request().method, + "url" to chain.request().url.host, + "status" to response.code.toString() + ) + ).record(duration, TimeUnit.MILLISECONDS) + response + }) + .protocols(protocols) + + b = networkInterceptors.map { b.addNetworkInterceptor(it) }.lastOrNull() ?: b + b = interceptors.map { b.addInterceptor(it) }.lastOrNull() ?: b + + return b.build() + } +} diff --git a/maestro-utils/src/main/kotlin/Metrics.kt b/maestro-utils/src/main/kotlin/Metrics.kt new file mode 100644 index 0000000000..d80d9911f9 --- /dev/null +++ b/maestro-utils/src/main/kotlin/Metrics.kt @@ -0,0 +1,81 @@ +package maestro.utils + +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Tag +import io.micrometer.core.instrument.Timer +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import java.util.concurrent.TimeUnit + + +// singleton to provide a metric manager across maestro code since there's so many singleton objects and passing it around would be a massive change +object MetricsProvider { + private var metrics: Metrics = NoOpMetrics() + + fun setMetrics(metrics: Metrics) { + this.metrics = metrics + } + + fun getInstance(): Metrics { + return metrics + } +} + +private fun toTags(map: Map): Iterable { + return map.filterValues { + it != null + }.map { Tag.of(it.key, it.value) }.toList() +} + +private fun prefixed(prefix: String?, name: String): String { + if (prefix == null) { + return name + } + return "$prefix.$name" +} + +open class Metrics( + val registry: MeterRegistry, + val prefix: String? = null, + val tags: Map = emptyMap(), +) { + fun measured(name: String, tags: Map = emptyMap(), block: () -> T): T { + val timer = Timer.builder(prefixed(prefix, name)).tags(toTags(tags)).register(registry) + counter(prefixed(prefix, "$name.calls"), tags).increment() + + val t0 = System.currentTimeMillis() + try { + return block() + } catch (e: Exception) { + registry.counter( + prefixed(prefix, "$name.errors"), + toTags(tags + ("exception" to e.javaClass.simpleName)) + ).increment() + throw e + } finally { + timer.record(System.currentTimeMillis() - t0, TimeUnit.MILLISECONDS) + } + } + + // get a metrics object that adds a certain prefix to all metrics + fun withPrefix(prefix: String): Metrics { + return Metrics(registry, prefixed(this.prefix, prefix)) + } + + // get a metrics object that adds labels to all metrics + fun withTags(tags: Map): Metrics { + return Metrics(registry, prefix, this.tags + tags) + } + + fun counter(name: String, labels: Map = emptyMap()): Counter { + return Counter.builder(prefixed(prefix, name)).tags(toTags(tags + labels)).register(registry) + } + + fun timer(name: String, labels: Map = emptyMap()): Timer { + return Timer.builder(prefixed(prefix, name)).tags(toTags(tags + labels)).register(registry) + } +} + +class NoOpMetrics : Metrics(SimpleMeterRegistry(), "noop", emptyMap()) { + +}