From d9c279c765c6f796199f71a2540567bdba1fbb3a Mon Sep 17 00:00:00 2001 From: Illia Sorokoumov Date: Sat, 2 Mar 2024 20:39:44 +0100 Subject: [PATCH] Kotlin-specific Observation API --- .../core/instrument/kotlin/ObserveAndAwait.kt | 47 +++++++++++ .../core/instrument/kotlin/ObserveAndGet.kt | 44 +++++++++++ .../kotlin/ObserveAndAwaitKtTests.kt | 79 +++++++++++++++++++ .../instrument/kotlin/ObserveAndGetKtTests.kt | 75 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 micrometer-core/src/main/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndAwait.kt create mode 100644 micrometer-core/src/main/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndGet.kt create mode 100644 micrometer-core/src/test/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndAwaitKtTests.kt create mode 100644 micrometer-core/src/test/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndGetKtTests.kt diff --git a/micrometer-core/src/main/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndAwait.kt b/micrometer-core/src/main/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndAwait.kt new file mode 100644 index 0000000000..3febd2a658 --- /dev/null +++ b/micrometer-core/src/main/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndAwait.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.core.instrument.kotlin + +import io.micrometer.observation.Observation +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext + +/** + * Observes the provided **suspending** block of code, which means the following: + * - Starts the [Observation] + * - Puts the [Observation] into [CoroutineContext] + * - Calls the provided [block] withing the augmented [CoroutineContext] + * - Signals the error to the [Observation] if any + * - Stops the [Observation] + * + * @param block the block of code to be observed + * @return the result of executing the provided block of code + */ +suspend fun Observation.observeAndAwait(block: suspend () -> T): T { + start() + return try { + withContext( + openScope().use { observationRegistry.asContextElement() }, + ) { + block() + } + } catch (error: Throwable) { + error(error) + throw error + } finally { + stop() + } +} diff --git a/micrometer-core/src/main/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndGet.kt b/micrometer-core/src/main/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndGet.kt new file mode 100644 index 0000000000..97255c308f --- /dev/null +++ b/micrometer-core/src/main/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndGet.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.core.instrument.kotlin + +import io.micrometer.observation.Observation + +/** + * Observes the provided block of code, which means the following: + * - Starts the [Observation] + * - Opens a scope + * - Calls the provided [block] + * - Closes the scope + * - Signals the error to the [Observation] if any + * - Stops the [Observation] + * + * For a suspending version, see [Observation.observeAndAwait] + * + * @param block the block of code to be observed + * @return the result of executing the provided block of code + */ +fun Observation.observeAndGet(block: () -> T): T { + start() + return try { + openScope().use { block() } + } catch (error: Throwable) { + error(error) + throw error + } finally { + stop() + } +} diff --git a/micrometer-core/src/test/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndAwaitKtTests.kt b/micrometer-core/src/test/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndAwaitKtTests.kt new file mode 100644 index 0000000000..86bb52fbfb --- /dev/null +++ b/micrometer-core/src/test/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndAwaitKtTests.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.core.instrument.kotlin + +import io.micrometer.observation.Observation +import io.micrometer.observation.ObservationHandler +import io.micrometer.observation.ObservationRegistry +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.BDDAssertions.assertThat +import org.assertj.core.api.BDDAssertions.then +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.isA +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +class ObserveAndAwaitKtTests { + + private val observationHandler = mock(ObservationHandler::class.java).also { handler -> + `when`(handler.supportsContext(isA(Observation.Context::class.java))) + .thenReturn(true) + } + + private val observationRegistry = ObservationRegistry.create().apply { + observationConfig().observationHandler(observationHandler) + } + + @Test + fun `should start and stop the observation when block is executed successfully`(): Unit = runBlocking { + val nonNullValue = "computed value" + + val result = Observation.createNotStarted("observeAndAwait", observationRegistry).observeAndAwait { + repeat(3) { delay(5) } + nonNullValue + } + + then(result).isSameAs(nonNullValue) + verify(observationHandler, times(1)).onStart(ArgumentMatchers.any()) + verify(observationHandler, times(1)).onStop(ArgumentMatchers.any()) + // 1 scope for call to openScope() + 1 scope for withContext + 3 scopes for 3 suspensions via delay + verify(observationHandler, times(1 + 1 + 3)).onScopeOpened(ArgumentMatchers.any()) + verify(observationHandler, times(1 + 1 + 3)).onScopeClosed(ArgumentMatchers.any()) + } + + @Test + fun `should start and stop both observation and scope when block throws an exception`(): Unit = runBlocking { + val errorMessage = "Something went wrong" + + val exception = runCatching { + Observation.createNotStarted("observeAndAwait", observationRegistry).observeAndAwait { + throw RuntimeException(errorMessage) + } + }.exceptionOrNull() + + assertThat(exception).hasMessage(errorMessage) + verify(observationHandler, times(1)).onError(ArgumentMatchers.any()) + verify(observationHandler, times(1)).onStart(ArgumentMatchers.any()) + verify(observationHandler, times(1)).onStop(ArgumentMatchers.any()) + // 1 scope for call to openScope() + 1 scope for withContext + verify(observationHandler, times(1 + 1)).onScopeOpened(ArgumentMatchers.any()) + verify(observationHandler, times(1 + 1)).onScopeClosed(ArgumentMatchers.any()) + } +} diff --git a/micrometer-core/src/test/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndGetKtTests.kt b/micrometer-core/src/test/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndGetKtTests.kt new file mode 100644 index 0000000000..e0b496d478 --- /dev/null +++ b/micrometer-core/src/test/kotlin/io/micrometer/core/instrument/kotlin/ObserveAndGetKtTests.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micrometer.core.instrument.kotlin + +import io.micrometer.observation.Observation +import io.micrometer.observation.ObservationHandler +import io.micrometer.observation.ObservationRegistry +import org.assertj.core.api.BDDAssertions.assertThat +import org.assertj.core.api.BDDAssertions.catchException +import org.assertj.core.api.BDDAssertions.then +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.isA +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +class ObserveAndGetKtTests { + + private val observationHandler = mock(ObservationHandler::class.java).also { handler -> + `when`(handler.supportsContext(isA(Observation.Context::class.java))) + .thenReturn(true) + } + + private val observationRegistry = ObservationRegistry.create().apply { + observationConfig().observationHandler(observationHandler) + } + + @Test + fun `should start and stop both observation and scope when block is executed successfully`() { + val nonNullValue = "computed value" + + val result = Observation.createNotStarted("observeAndGet", observationRegistry).observeAndGet { + nonNullValue + } + + then(result).isSameAs(nonNullValue) + verify(observationHandler, times(1)).onStart(ArgumentMatchers.any()) + verify(observationHandler, times(1)).onStop(ArgumentMatchers.any()) + verify(observationHandler, times(1)).onScopeOpened(ArgumentMatchers.any()) + verify(observationHandler, times(1)).onScopeClosed(ArgumentMatchers.any()) + } + + @Test + fun `should start and stop both observation and scope when block throws an exception`() { + val errorMessage = "Something went wrong" + + val exception = catchException { + Observation.createNotStarted("observeAndGet", observationRegistry).observeAndGet { + throw RuntimeException(errorMessage) + } + } + + assertThat(exception).hasMessage(errorMessage) + verify(observationHandler, times(1)).onError(ArgumentMatchers.any()) + verify(observationHandler, times(1)).onStart(ArgumentMatchers.any()) + verify(observationHandler, times(1)).onStop(ArgumentMatchers.any()) + verify(observationHandler, times(1)).onScopeOpened(ArgumentMatchers.any()) + verify(observationHandler, times(1)).onScopeClosed(ArgumentMatchers.any()) + } +}