-
Notifications
You must be signed in to change notification settings - Fork 1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: client metrics #3988
base: main
Are you sure you want to change the base?
feat: client metrics #3988
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -171,6 +171,54 @@ public class HttpRequestBuilder : HttpMessageBuilder { | |
return attributes.getOrNull(ENGINE_CAPABILITIES_KEY)?.get(key) as T? | ||
} | ||
|
||
/** | ||
* Substitute URI path and/or query parameters with provided values | ||
* | ||
* Example: | ||
* ``` | ||
* client.get("https://blog.jetbrains.com/kotlin/{year}/{month}/{name}/") { | ||
* pathParameters( | ||
* "year" to 2023, | ||
* "month" to "11", | ||
* "name" to "kotlin-1-9-20-released", | ||
* ) | ||
* }.bodyAsText() | ||
* ``` | ||
* | ||
* @param parameters pairs of parameter name and value to substitute | ||
* @see pathParameter | ||
*/ | ||
public fun pathParameters(parameters: Iterable<Pair<String, Any>>) { | ||
val currentUrl = url.build() | ||
// save original URI pattern | ||
attributes.computeIfAbsent(UriPatternAttributeKey) { "$currentUrl" } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why does it need to be saved as a |
||
|
||
// replace path/query parameter in the URI pattern | ||
url( | ||
currentUrl.substitutePathParameters(parameters), | ||
) | ||
} | ||
|
||
/** | ||
* Substitute URI path and/or query parameter with provided value | ||
* | ||
* Example: | ||
* ``` | ||
* client.get("https://blog.jetbrains.com/kotlin/{year}/{month}/{name}/") { | ||
* pathParameter("year", 2023) | ||
* pathParameter("month", "11") | ||
* pathParameter("name", "kotlin-1-9-20-released") | ||
* }.bodyAsText() | ||
* ``` | ||
* | ||
* @param key parameter name | ||
* @param value parameter value | ||
* @see pathParameters | ||
*/ | ||
public fun pathParameter(key: String, value: Any) { | ||
pathParameters(listOf(key to value)) | ||
} | ||
|
||
public companion object | ||
} | ||
|
||
|
@@ -354,3 +402,31 @@ public class SSEClientResponseAdapter : ResponseAdapter { | |
} | ||
} | ||
} | ||
|
||
/** | ||
* Attribute key for URI pattern | ||
*/ | ||
public val UriPatternAttributeKey: AttributeKey<String> = AttributeKey("UriPatternAttributeKey") | ||
|
||
private fun Url.substitutePathParameters(substitutions: Iterable<Pair<String, Any>>) = | ||
URLBuilder().also { builder -> | ||
val substitutionsMap = substitutions.associate { (key, value) -> "{$key}" to "$value" } | ||
|
||
builder.host = host | ||
builder.protocol = protocol | ||
builder.port = port | ||
builder.pathSegments = | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about |
||
pathSegments.map { pathSegment -> | ||
substitutionsMap[pathSegment] ?: pathSegment | ||
} | ||
parameters.forEach { parameterName, parameterValues -> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We also replace query parameters here, so the names |
||
parameterValues.forEach { parameterValue -> | ||
builder.parameters.append( | ||
name = parameterName, | ||
value = substitutionsMap[parameterValue] ?: parameterValue, | ||
) | ||
} | ||
} | ||
builder.user = user | ||
builder.password = password | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to copy |
||
}.build() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
/* | ||
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. | ||
*/ | ||
|
||
kotlin.sourceSets { | ||
jvmMain { | ||
dependencies { | ||
api(libs.micrometer) | ||
implementation(project(":ktor-client:ktor-client-core")) | ||
} | ||
} | ||
jvmTest { | ||
dependencies{ | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,274 @@ | ||
/* | ||
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. | ||
*/ | ||
|
||
package io.ktor.client.plugins.metrics.micrometer | ||
|
||
import io.ktor.client.* | ||
import io.ktor.client.call.* | ||
import io.ktor.client.plugins.api.* | ||
import io.ktor.client.request.* | ||
import io.ktor.client.statement.* | ||
import io.ktor.util.* | ||
import io.ktor.util.pipeline.* | ||
import io.ktor.utils.io.* | ||
import io.micrometer.core.instrument.* | ||
import io.micrometer.core.instrument.binder.http.Outcome | ||
import io.micrometer.core.instrument.logging.* | ||
|
||
public const val URI_PATTERN: String = "URI_PATTERN" | ||
|
||
private const val TAG_TARGET_SCHEME = "target.scheme" | ||
private const val TAG_TARGET_HOST = "target.host" | ||
private const val TAG_TARGET_PORT = "target.port" | ||
private const val TAG_URI = "uri" | ||
private const val TAG_METHOD = "method" | ||
private const val TAG_STATUS = "status" | ||
private const val TAG_EXCEPTION = "exception" | ||
private const val TAG_VALUE_UNKNOWN = "UNKNOWN" | ||
|
||
private val EMPTY_EXCEPTION_TAG: Tag = Tag.of(TAG_EXCEPTION, "null") | ||
|
||
private val QUERY_PART_REGEX = "\\?.*$".toRegex() | ||
|
||
private val ClientCallTimer = AttributeKey<Timer.Sample>("CallTimer") | ||
|
||
/** | ||
* A configuration for the [MicrometerMetrics] plugin. | ||
*/ | ||
@KtorDsl | ||
public class MicrometerMetricsConfig { | ||
|
||
/** | ||
* Specifies the base name (prefix) of Ktor metrics used for monitoring HTTP client requests. | ||
* For example, the default "ktor.http.client.requests" values results in the following metrics: | ||
* - "ktor.http.client.requests.count" | ||
* - "ktor.http.client.requests.seconds.max" | ||
* | ||
* If you change it to "custom.metric.name", the mentioned metrics will look as follows: | ||
* - "custom.metric.name.count" | ||
* - "custom.metric.name.seconds.max" | ||
* @see [MicrometerMetrics] | ||
*/ | ||
public var metricName: String = "ktor.http.client.requests" | ||
|
||
/** | ||
* Extra tags to add to the metrics | ||
*/ | ||
public var extraTags: Iterable<Tag> = emptyList() | ||
|
||
/** | ||
* Whether to drop the query part of the URL in the tag or not. Default: `true` | ||
*/ | ||
public var dropQueryPartInUriTag: Boolean = true | ||
|
||
/** | ||
* Whether to use expanded URL when the pattern is unavailable or not. Default: `true` | ||
* | ||
* Note that setting this option to `true` without using URI templates with [HttpRequestBuilder.pathParameters] | ||
* might lead to cardinality blow up. | ||
*/ | ||
public var useExpandedUrlWhenPatternUnavailable: Boolean = true | ||
|
||
/** | ||
* Specifies the meter registry for your monitoring system. | ||
* The example below shows how to create the `PrometheusMeterRegistry`: | ||
* ```kotlin | ||
* install(MicrometerMetrics) { | ||
* registry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT) | ||
* } | ||
* ``` | ||
* @see [MicrometerMetrics] | ||
*/ | ||
public var registry: MeterRegistry = LoggingMeterRegistry() | ||
set(value) { | ||
field.close() | ||
field = value | ||
} | ||
} | ||
|
||
/** | ||
* A client's plugin that provides the capability to meter HTTP calls with micrometer | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you please add in Kdoc example of plugin usage? |
||
* | ||
*/ | ||
public val MicrometerMetrics: ClientPlugin<MicrometerMetricsConfig> = | ||
createClientPlugin("MicrometerMetrics", ::MicrometerMetricsConfig) { | ||
val metricName = pluginConfig.metricName | ||
val extraTags = pluginConfig.extraTags | ||
val dropQueryPartInUriTag = pluginConfig.dropQueryPartInUriTag | ||
val useExpandedUrlWhenPatternUnavailable = pluginConfig.useExpandedUrlWhenPatternUnavailable | ||
val meterRegistry = pluginConfig.registry | ||
|
||
fun Timer.Builder.addDefaultTags(request: HttpRequestBuilder) = | ||
tags( | ||
Tags.of( | ||
Tag.of(TAG_TARGET_SCHEME, request.url.protocol.name), | ||
Tag.of(TAG_TARGET_HOST, request.host), | ||
Tag.of(TAG_TARGET_PORT, "${request.port}"), | ||
Tag.of(TAG_METHOD, request.method.value), | ||
Tag.of( | ||
TAG_URI, | ||
request.attributes.getOrNull(UriPatternAttributeKey) | ||
.let { it ?: request.url.takeIf { useExpandedUrlWhenPatternUnavailable }?.toString() } | ||
?.removeHostPart(request.host) | ||
?.let { it.takeUnless { dropQueryPartInUriTag } ?: it.removeQueryPart() } | ||
?: TAG_VALUE_UNKNOWN | ||
), | ||
) | ||
).tags(extraTags) | ||
|
||
fun Timer.Builder.addDefaultTags(request: HttpRequest) = | ||
tags( | ||
Tags.of( | ||
Tag.of(TAG_TARGET_SCHEME, request.url.protocol.name), | ||
Tag.of(TAG_TARGET_HOST, request.url.host), | ||
Tag.of(TAG_TARGET_PORT, "${request.url.port}"), | ||
Tag.of(TAG_METHOD, request.method.value), | ||
Tag.of( | ||
TAG_URI, | ||
request.attributes.getOrNull(UriPatternAttributeKey) | ||
.let { it ?: request.url.takeIf { useExpandedUrlWhenPatternUnavailable }?.toString() } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we extract it so it does not have copypaste? |
||
?.removeHostPart(request.url.host) | ||
?.let { it.takeUnless { dropQueryPartInUriTag } ?: it.removeQueryPart() } | ||
?: TAG_VALUE_UNKNOWN | ||
), | ||
) | ||
).tags(extraTags) | ||
|
||
on(SendHook) { request -> | ||
val timer = Timer.start(meterRegistry) | ||
request.attributes.put(ClientCallTimer, timer) | ||
|
||
try { | ||
proceed() | ||
} catch (cause: Throwable) { | ||
timer.stop( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we add |
||
Timer.builder(metricName) | ||
.addDefaultTags(request) | ||
.tags( | ||
Tags.of( | ||
Outcome.CLIENT_ERROR.asTag(), | ||
Tag.of(TAG_STATUS, TAG_VALUE_UNKNOWN), | ||
cause.toTag(), | ||
) | ||
) | ||
.register(meterRegistry) | ||
) | ||
throw cause | ||
} | ||
} | ||
|
||
on(ReceiveHook) { call -> | ||
val timer = call.attributes.getOrNull(ClientCallTimer) | ||
|
||
try { | ||
proceed() | ||
} catch (cause: Throwable) { | ||
timer?.stop( | ||
Timer.builder(metricName) | ||
.addDefaultTags(call.request) | ||
.tags( | ||
Tags.of( | ||
Outcome.CLIENT_ERROR.asTag(), | ||
Tag.of(TAG_STATUS, TAG_VALUE_UNKNOWN), | ||
cause.toTag(), | ||
) | ||
) | ||
.register(meterRegistry) | ||
) | ||
throw cause | ||
} | ||
} | ||
|
||
on(ResponseHook) { response -> | ||
val timer = response.call.attributes.getOrNull(ClientCallTimer) | ||
|
||
try { | ||
proceed() | ||
timer?.stop( | ||
Timer.builder(metricName) | ||
.addDefaultTags(response.request) | ||
.tags( | ||
Tags.of( | ||
Outcome.forStatus(response.status.value).asTag(), | ||
Tag.of(TAG_STATUS, "${response.status.value}"), | ||
EMPTY_EXCEPTION_TAG, | ||
) | ||
) | ||
.register(meterRegistry) | ||
) | ||
} catch (cause: Throwable) { | ||
timer?.stop( | ||
Timer.builder(metricName) | ||
.addDefaultTags(response.request) | ||
.tags( | ||
Tags.of( | ||
Outcome.CLIENT_ERROR.asTag(), | ||
Tag.of(TAG_STATUS, TAG_VALUE_UNKNOWN), | ||
cause.toTag(), | ||
) | ||
) | ||
.register(meterRegistry) | ||
) | ||
throw cause | ||
} | ||
} | ||
} | ||
|
||
private fun Throwable.toTag(): Tag = | ||
Tag.of( | ||
TAG_EXCEPTION, | ||
cause?.javaClass?.simpleName ?: javaClass.simpleName, | ||
) | ||
|
||
private fun String.removeHostPart(host: String) = replace("^.*$host[^/]*".toRegex(), "") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we extract regex here so as not to compile it every time? |
||
|
||
private fun String.removeQueryPart() = replace(QUERY_PART_REGEX, "") | ||
|
||
private object ResponseHook : ClientHook<suspend ResponseHook.Context.(response: HttpResponse) -> Unit> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we, instead of creating new Hooks, use functions |
||
|
||
class Context(private val context: PipelineContext<HttpResponse, Unit>) { | ||
suspend fun proceed() = context.proceed() | ||
} | ||
|
||
override fun install( | ||
client: HttpClient, | ||
handler: suspend Context.(response: HttpResponse) -> Unit | ||
) { | ||
client.receivePipeline.intercept(HttpReceivePipeline.State) { | ||
handler(Context(this), subject) | ||
} | ||
} | ||
} | ||
|
||
private object SendHook : ClientHook<suspend SendHook.Context.(response: HttpRequestBuilder) -> Unit> { | ||
|
||
class Context(private val context: PipelineContext<Any, HttpRequestBuilder>) { | ||
suspend fun proceed() = context.proceed() | ||
} | ||
|
||
override fun install( | ||
client: HttpClient, | ||
handler: suspend Context.(request: HttpRequestBuilder) -> Unit | ||
) { | ||
client.sendPipeline.intercept(HttpSendPipeline.Monitoring) { | ||
handler(Context(this), context) | ||
} | ||
} | ||
} | ||
|
||
private object ReceiveHook : ClientHook<suspend ReceiveHook.Context.(call: HttpClientCall) -> Unit> { | ||
|
||
class Context(private val context: PipelineContext<HttpResponseContainer, HttpClientCall>) { | ||
suspend fun proceed() = context.proceed() | ||
} | ||
|
||
override fun install( | ||
client: HttpClient, | ||
handler: suspend Context.(call: HttpClientCall) -> Unit | ||
) { | ||
client.responsePipeline.intercept(HttpResponsePipeline.Receive) { | ||
handler(Context(this), context) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we remove
url.build()
here and make it call only once? Maybe we can useHttpRequestBuilder.attributes
to save parameters and build later