From c00e2c798b24008272ea32117f649c60048acc1c Mon Sep 17 00:00:00 2001 From: Luca Rospocher Date: Tue, 7 Feb 2023 17:52:53 +0100 Subject: [PATCH] Add Micrometer integration to ktor client * Services using kevin-jvm can pass their own Micrometer registry and the ktor client will also registry a Timer for any Platform API request * Move version to 0.2.9 --- build.gradle.kts | 3 +- buildSrc/src/main/kotlin/Dependencies.kt | 1 + .../kotlin/eu/kevin/api/models/ErrorCode.kt | 2 +- .../plugins/KtorClientMicrometerMetrics.kt | 99 +++++++++++++++++++ .../kotlin/eu/kevin/api/services/Client.kt | 21 ++-- .../api/services/account/AccountClient.kt | 10 +- .../eu/kevin/api/services/auth/AuthClient.kt | 11 ++- .../api/services/general/GeneralClient.kt | 22 ++++- .../api/services/payment/PaymentClient.kt | 7 +- 9 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 src/main/kotlin/eu/kevin/api/plugins/KtorClientMicrometerMetrics.kt diff --git a/build.gradle.kts b/build.gradle.kts index 708defc..6316e08 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ plugins { } group = "eu.kevin" -version = "0.2.7" +version = "0.2.9" repositories { mavenCentral() @@ -22,6 +22,7 @@ dependencies { implementation("io.ktor:ktor-client-core:${Versions.KTOR}") implementation("io.ktor:ktor-client-cio:${Versions.KTOR}") implementation("io.ktor:ktor-client-serialization:${Versions.KTOR}") + implementation("io.micrometer:micrometer-registry-prometheus:${Versions.MICROMETER}") testImplementation(kotlin("test")) testImplementation("io.ktor:ktor-client-mock:${Versions.KTOR}") diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 07fbde2..bcff952 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -3,4 +3,5 @@ object Versions { const val KTOR = "1.6.5" const val KTLINT = "10.2.0" const val NEXUS_PUBLISH = "1.1.0" + const val MICROMETER = "1.9.1" } \ No newline at end of file diff --git a/src/main/kotlin/eu/kevin/api/models/ErrorCode.kt b/src/main/kotlin/eu/kevin/api/models/ErrorCode.kt index c474a70..4ae5f8a 100644 --- a/src/main/kotlin/eu/kevin/api/models/ErrorCode.kt +++ b/src/main/kotlin/eu/kevin/api/models/ErrorCode.kt @@ -24,7 +24,7 @@ enum class ErrorCode(val code: Int) { @SerialName("20016") INVALID_DEBTOR_ACCOUNT(20016), @SerialName("20017") INSUFFICIENT_FUNDS(20017), @SerialName("20020") CHOSEN_SCA_METHOD_REQUIRE_VALID_PSU_PERSON_ID(20020), - @SerialName("20017") CHOSEN_SCA_METHOD_REQUIRE_VALID_PSU_PHONE_NUMBER(20021), + @SerialName("20021") CHOSEN_SCA_METHOD_REQUIRE_VALID_PSU_PHONE_NUMBER(20021), @SerialName("20022") CREDITOR_ACCOUNT_NUMBER_INVALID_OR_MISSING(20022), @SerialName("20023") THE_ACCOUNT_NUMBER_AND_THE_NAME_DO_NOT_COINCIDE(20023), @SerialName("20024") TRANSACTION_CURRENCY_IS_INVALID_OR_MISSING(20024), diff --git a/src/main/kotlin/eu/kevin/api/plugins/KtorClientMicrometerMetrics.kt b/src/main/kotlin/eu/kevin/api/plugins/KtorClientMicrometerMetrics.kt new file mode 100644 index 0000000..b6e04c1 --- /dev/null +++ b/src/main/kotlin/eu/kevin/api/plugins/KtorClientMicrometerMetrics.kt @@ -0,0 +1,99 @@ +package eu.kevin.api.plugins + +import io.ktor.client.* +import io.ktor.client.features.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.client.utils.* +import io.ktor.http.* +import io.ktor.util.* +import io.ktor.util.pipeline.* +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Tag +import io.micrometer.core.instrument.Tags +import io.micrometer.core.instrument.Timer +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +/** + * Feature to get statistics from KTor client. Partially lifted off https://youtrack.jetbrains.com/issue/KTOR-1004 + */ +class KtorClientMicrometerMetrics internal constructor(val meterRegistry: MeterRegistry) { + private fun stopTimer(sample: Timer.Sample, url: Url, path: String, method: String, status: Int, throwable: Throwable?) { + val parameters = mutableMapOf( + "host" to url.host, + "path" to path, + "method" to method, + "status" to status.toString() + ) + + throwable?.let { + parameters["throwable"] = it.javaClass.simpleName + } + + sample.stop( + meterRegistry.timer( + "http.client.requests", + Tags.of((parameters).map { Tag.of(it.key, it.value) }) + ) + ) + } + + class Config { + lateinit var meterRegistry: MeterRegistry + } + + companion object Feature : HttpClientFeature { + override val key: AttributeKey = AttributeKey("ClientMetrics") + + val pathAttributeKey: AttributeKey = AttributeKey("Path") + private val sampleAttributeKey: AttributeKey = AttributeKey("TimerSample") + private val monitoringPhase = PipelinePhase("Monitoring") + + override fun prepare(block: Config.() -> Unit): KtorClientMicrometerMetrics { + val config = Config().apply(block) + return KtorClientMicrometerMetrics(config.meterRegistry) + } + + @OptIn(InternalAPI::class) + override fun install(plugin: KtorClientMicrometerMetrics, scope: HttpClient) { + scope.receivePipeline.insertPhaseAfter(HttpReceivePipeline.Before, monitoringPhase) + + scope.sendPipeline.intercept(HttpSendPipeline.Monitoring) { + val sample = Timer.start(plugin.meterRegistry) + try { + context.attributes.put(sampleAttributeKey, sample) + proceed() + } catch (cause: Throwable) { + val url = context.url.build() + + plugin.stopTimer( + sample = sample, + url = url, + path = context.attributes.getOrNull(pathAttributeKey) + ?: URLDecoder.decode(url.encodedPath, StandardCharsets.UTF_8), + method = context.method.value, + status = 500, + throwable = cause + ) + throw cause.unwrapCancellationException() + } + } + + scope.receivePipeline.intercept(monitoringPhase) { + val url = it.call.request.url + + plugin.stopTimer( + sample = it.request.attributes[sampleAttributeKey], + url = url, + path = it.request.attributes.getOrNull(pathAttributeKey) + ?: URLDecoder.decode(url.encodedPath, StandardCharsets.UTF_8), + method = it.request.method.value, + status = it.call.response.status.value, + throwable = null + ) + proceed() + } + } + } +} diff --git a/src/main/kotlin/eu/kevin/api/services/Client.kt b/src/main/kotlin/eu/kevin/api/services/Client.kt index 00535b2..2888ee6 100644 --- a/src/main/kotlin/eu/kevin/api/services/Client.kt +++ b/src/main/kotlin/eu/kevin/api/services/Client.kt @@ -3,6 +3,7 @@ package eu.kevin.api.services import eu.kevin.api.Dependencies import eu.kevin.api.Endpoint import eu.kevin.api.models.Authorization +import eu.kevin.api.plugins.KtorClientMicrometerMetrics import eu.kevin.api.services.account.AccountClient import eu.kevin.api.services.auth.AuthClient import eu.kevin.api.services.general.GeneralClient @@ -11,31 +12,32 @@ import io.ktor.client.* import io.ktor.client.features.* import io.ktor.client.request.* import io.ktor.http.* -import kotlinx.serialization.json.Json +import io.micrometer.core.instrument.MeterRegistry import java.net.URI class Client internal constructor( private val authorization: Authorization, private val apiUrl: String, private val httpClient: HttpClient, - private val serializer: Json, - private val customHeaders: Map + private val customHeaders: Map, + private val micrometerRegistry: MeterRegistry? ) { val paymentClient by lazy { PaymentClient(httpClient = httpClient.withAuthorization()) } val authClient by lazy { AuthClient(httpClient = httpClient.withAuthorization()) } val generalClient by lazy { GeneralClient(httpClient = httpClient.withAuthorization()) } - val accountClient by lazy { AccountClient(httpClient = httpClient.withAuthorization(), serializer = serializer) } + val accountClient by lazy { AccountClient(httpClient = httpClient.withAuthorization()) } constructor( authorization: Authorization, apiUrl: String = Endpoint.BASE, - customHeaders: Map = mapOf() + customHeaders: Map = mapOf(), + micrometerRegistry: MeterRegistry? = null ) : this( authorization = authorization, apiUrl = apiUrl, httpClient = Dependencies.httpClient, - serializer = Dependencies.serializer, - customHeaders = customHeaders + customHeaders = customHeaders, + micrometerRegistry = micrometerRegistry ) private fun HttpClient.withAuthorization() = this.config { @@ -51,5 +53,10 @@ class Client internal constructor( header("Client-Secret", authorization.clientSecret) customHeaders.forEach { header(it.key, it.value) } } + if (micrometerRegistry != null) { + install(KtorClientMicrometerMetrics) { + meterRegistry = micrometerRegistry + } + } } } diff --git a/src/main/kotlin/eu/kevin/api/services/account/AccountClient.kt b/src/main/kotlin/eu/kevin/api/services/account/AccountClient.kt index 9ede1c5..598e268 100644 --- a/src/main/kotlin/eu/kevin/api/services/account/AccountClient.kt +++ b/src/main/kotlin/eu/kevin/api/services/account/AccountClient.kt @@ -12,16 +12,16 @@ import eu.kevin.api.models.account.detail.GetAccountDetailsRequest import eu.kevin.api.models.account.list.AccountResponse import eu.kevin.api.models.account.transaction.request.GetAccountTransactionsRequest import eu.kevin.api.models.account.transaction.response.AccountTransactionResponse +import eu.kevin.api.plugins.KtorClientMicrometerMetrics import io.ktor.client.* import io.ktor.client.request.* -import kotlinx.serialization.json.Json +import io.ktor.util.* /** * Implements API Methods of the [Account information service](https://docs.kevin.eu/public/platform/v0.3#tag/Account-Information-Service) */ class AccountClient internal constructor( - private val httpClient: HttpClient, - private val serializer: Json + private val httpClient: HttpClient ) { /** @@ -33,6 +33,7 @@ class AccountClient internal constructor( path = Endpoint.Paths.Account.getAccountsList() ) { appendAccountRequestHeaders(headers = request) + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Account.getAccountsList()) }.data /** @@ -44,6 +45,7 @@ class AccountClient internal constructor( path = Endpoint.Paths.Account.getAccountDetails(accountId = request.accountId) ) { appendAccountRequestHeaders(headers = request.headers) + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Account.getAccountDetails("$")) } /** @@ -57,6 +59,7 @@ class AccountClient internal constructor( appendAccountRequestHeaders(headers = request.headers) parameter("dateFrom", request.dateFrom) parameter("dateTo", request.dateTo) + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Account.getAccountTransactions("$")) }.data /** @@ -68,6 +71,7 @@ class AccountClient internal constructor( path = Endpoint.Paths.Account.getAccountBalance(accountId = request.accountId) ) { appendAccountRequestHeaders(headers = request.headers) + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Account.getAccountBalance("$")) }.data private fun HttpRequestBuilder.appendAccountRequestHeaders(headers: AccountRequestHeaders) { diff --git a/src/main/kotlin/eu/kevin/api/services/auth/AuthClient.kt b/src/main/kotlin/eu/kevin/api/services/auth/AuthClient.kt index 9b5c10b..ec00952 100644 --- a/src/main/kotlin/eu/kevin/api/services/auth/AuthClient.kt +++ b/src/main/kotlin/eu/kevin/api/services/auth/AuthClient.kt @@ -12,6 +12,7 @@ import eu.kevin.api.models.auth.token.request.RefreshTokenRequest import eu.kevin.api.models.auth.token.response.ReceiveTokenResponse import eu.kevin.api.models.auth.tokenContent.ReceiveTokenContentRequest import eu.kevin.api.models.auth.tokenContent.ReceiveTokenContentResponse +import eu.kevin.api.plugins.KtorClientMicrometerMetrics import io.ktor.client.* import io.ktor.client.request.* import io.ktor.http.* @@ -47,6 +48,7 @@ class AuthClient internal constructor( webhookUrl?.let { append("Webhook-URL", it) } } } + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Auth.startAuthentication()) }.run { copy( authorizationLink = Url(authorizationLink) @@ -63,7 +65,9 @@ class AuthClient internal constructor( httpClient.post( path = Endpoint.Paths.Auth.receiveToken(), body = request - ) + ) { + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Auth.receiveToken()) + } /** * API Method: [Receive token](https://docs.kevin.eu/public/platform/v0.3#operation/receiveToken), with `grantType` set to `refreshToken` @@ -73,7 +77,9 @@ class AuthClient internal constructor( httpClient.post( path = Endpoint.Paths.Auth.receiveToken(), body = request - ) + ) { + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Auth.receiveToken()) + } /** * API Method: [Receive token content](https://docs.kevin.eu/public/platform/v0.3#operation/receiveTokenContent) @@ -86,5 +92,6 @@ class AuthClient internal constructor( headers { append(HttpHeaders.Authorization, request.accessToken.appendAtStartIfNotExist("Bearer ")) } + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Auth.receiveTokenContent()) } } diff --git a/src/main/kotlin/eu/kevin/api/services/general/GeneralClient.kt b/src/main/kotlin/eu/kevin/api/services/general/GeneralClient.kt index a5995cc..e9391de 100644 --- a/src/main/kotlin/eu/kevin/api/services/general/GeneralClient.kt +++ b/src/main/kotlin/eu/kevin/api/services/general/GeneralClient.kt @@ -5,6 +5,7 @@ import eu.kevin.api.exceptions.KevinApiErrorException import eu.kevin.api.models.ResponseArray import eu.kevin.api.models.general.bank.BankResponse import eu.kevin.api.models.general.projectSettings.GetProjectSettingsResponse +import eu.kevin.api.plugins.KtorClientMicrometerMetrics import io.ktor.client.* import io.ktor.client.request.* @@ -21,7 +22,9 @@ class GeneralClient internal constructor( suspend fun getSupportedCountries(): List = httpClient.get>( path = Endpoint.Paths.General.getSupportedCountries() - ).data + ) { + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getSupportedCountries()) + }.data /** * API Method: [Get supported banks](https://docs.kevin.eu/public/platform/v0.3#operation/getBanks) @@ -33,6 +36,7 @@ class GeneralClient internal constructor( path = Endpoint.Paths.General.getSupportedBanks() ) { parameter("countryCode", countryCode) + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getSupportedBanks()) }.data /** @@ -42,7 +46,9 @@ class GeneralClient internal constructor( suspend fun getSupportedBank(bankId: String): BankResponse = httpClient.get( path = Endpoint.Paths.General.getSupportedBank(bankId = bankId) - ) + ) { + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getSupportedBank("$")) + } /** * API Method: [Get supported bank by card number piece](https://docs.kevin.eu/public/platform/v0.3#operation/getBankByCardNumberPiece) @@ -51,7 +57,9 @@ class GeneralClient internal constructor( suspend fun getSupportedBankByCardNumberPiece(cardNumberPiece: String): BankResponse = httpClient.get( path = Endpoint.Paths.General.getSupportedBankByCardNumberPiece(cardNumberPiece = cardNumberPiece) - ) + ) { + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getSupportedBankByCardNumberPiece("$")) + } /** * API Method: [Get payment methods](https://docs.kevin.eu/public/platform/v0.3#operation/getPaymentMethods) @@ -60,7 +68,9 @@ class GeneralClient internal constructor( suspend fun getPaymentMethods(): List = httpClient.get>( path = Endpoint.Paths.General.getPaymentMethods() - ).data + ) { + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getPaymentMethods()) + }.data /** * API Method: [Get project settings](https://docs.kevin.eu/public/platform/v0.3#operation/getProjectSettings) @@ -69,5 +79,7 @@ class GeneralClient internal constructor( suspend fun getProjectSettings(): GetProjectSettingsResponse = httpClient.get( path = Endpoint.Paths.General.getProjectSettings() - ) + ) { + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.General.getProjectSettings()) + } } diff --git a/src/main/kotlin/eu/kevin/api/services/payment/PaymentClient.kt b/src/main/kotlin/eu/kevin/api/services/payment/PaymentClient.kt index cfea6c9..79b5554 100644 --- a/src/main/kotlin/eu/kevin/api/services/payment/PaymentClient.kt +++ b/src/main/kotlin/eu/kevin/api/services/payment/PaymentClient.kt @@ -12,6 +12,7 @@ import eu.kevin.api.models.payment.paymentStatus.GetPaymentStatusResponse import eu.kevin.api.models.payment.refund.InitiatePaymentRefundRequest import eu.kevin.api.models.payment.refund.InitiatePaymentRefundRequestBody import eu.kevin.api.models.payment.refund.InitiatePaymentRefundResponse +import eu.kevin.api.plugins.KtorClientMicrometerMetrics import io.ktor.client.* import io.ktor.client.request.* import io.ktor.http.* @@ -51,6 +52,7 @@ class PaymentClient internal constructor( webhookUrl?.let { append("Webhook-URL", it) } } } + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Payment.initiatePayment()) }.run { copy( confirmLink = confirmLink?.let { @@ -66,7 +68,9 @@ class PaymentClient internal constructor( suspend fun getPaymentStatus(request: GetPaymentStatusRequest): GetPaymentStatusResponse = httpClient.get( path = Endpoint.Paths.Payment.getPaymentStatus(paymentId = request.paymentId) - ) + ) { + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Payment.getPaymentStatus("$")) + } /** * API Method: [Initiate payment refund](https://docs.kevin.eu/public/platform/v0.3#operation/initiatePaymentRefund) @@ -82,5 +86,6 @@ class PaymentClient internal constructor( webhookUrl?.let { append("Webhook-URL", it) } } } + attributes.put(KtorClientMicrometerMetrics.pathAttributeKey, Endpoint.Paths.Payment.initiatePaymentRefund("$")) } }