Skip to content

Commit

Permalink
Implement metrics endpoint in metrics lib (#42)
Browse files Browse the repository at this point in the history
* Implement metrics endpoint in metrics lib

* Add tests for metrics endpoint

* Update METRICS.md

* Add tests for metrics endpoint
  • Loading branch information
turchenkoalex authored Nov 28, 2024
1 parent acd3834 commit a724899
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .idea/kotlinc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions docs/METRICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,35 @@ Kotlet.routing {
// your routes
}
```

## Metrics scrape endpoint

> [!IMPORTANT]
> For using scrape metrics endpoint you must add to your dependencies
> [prometheus-metrics-exporter-servlet-jakarta](https://mvnrepository.com/artifact/io.prometheus/prometheus-metrics-exporter-servlet-jakarta)
> library.

To expose metrics for Prometheus, you can use the `installMetricsScrape` method. Add it to your kotlet router like this:

```kotlin
val appRouting = Kotlet.routing {
installMetrics(kotletMetrics) // Now all requests of this routing will be measured
get("/hello") { call ->
call.respondText("Hello, World!")
}
}

val auxRouting = Kotlet.routing {
installMetricsScrape {
path = "/metrics"
}
}

// Combine routings
Kotlet.servlet(
routings = listOf(appRouting, auxRouting)
)
```

After it metrics will be available at `/metrics` endpoint in OpenMetrics format.
2 changes: 2 additions & 0 deletions metrics/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ dependencies {
implementation(project(":core"))
compileOnly(libs.jakarta.api)
compileOnly(libs.prometheus.metrics.core)
compileOnly(libs.prometheus.metrics.exporter.servlet.jakarta)

testImplementation(project(":mocks"))
testImplementation(libs.kotlin.test)
testImplementation(libs.mockk)
testImplementation(libs.jakarta.api)
testImplementation(libs.prometheus.metrics.core)
testImplementation(libs.prometheus.metrics.exporter.servlet.jakarta)
}

tasks.test {
Expand Down
57 changes: 57 additions & 0 deletions metrics/src/main/kotlin/kotlet/metrics/MetricsScrape.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package kotlet.metrics

import io.prometheus.metrics.config.PrometheusProperties
import io.prometheus.metrics.model.registry.PrometheusRegistry
import kotlet.Routing
import kotlet.prometheus.PrometheusScrapeEndpointHandler

/**
* Install a scrape endpoint for Prometheus metrics.
*/
fun Routing.installMetricsScrape(configure: MetricsScrapeConfigBuilder.() -> Unit = {}) {
val builder = MetricsScrapeConfigBuilder()
builder.configure()
val config = builder.build()

get(config.path, PrometheusScrapeEndpointHandler(config.config, config.registry))
}

/**
* Configuration for the scrape endpoint.
*/
internal data class MetricsScrapeConfig(
val path: String,
val registry: PrometheusRegistry,
val config: PrometheusProperties
)

/**
* Builder for [MetricsScrapeConfig].
*/
class MetricsScrapeConfigBuilder internal constructor() {
/**
* Path to the scrape endpoint
* Default: /metrics
*/
var path: String = "/metrics"

/**
* Prometheus registry to scrape metrics from
* Default: [PrometheusRegistry.defaultRegistry]
*/
var registry: PrometheusRegistry = PrometheusRegistry.defaultRegistry

/**
* Prometheus configuration
* Default: [PrometheusProperties.get]
*/
var config: PrometheusProperties = PrometheusProperties.get()

internal fun build(): MetricsScrapeConfig {
return MetricsScrapeConfig(
path = path,
registry = registry,
config = config,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kotlet.prometheus

import io.prometheus.metrics.config.PrometheusProperties
import io.prometheus.metrics.exporter.common.PrometheusScrapeHandler
import io.prometheus.metrics.exporter.servlet.jakarta.HttpExchangeAdapter
import io.prometheus.metrics.model.registry.PrometheusRegistry
import kotlet.Handler
import kotlet.HttpCall

/**
* Handler for Prometheus scrape endpoint.
*/
internal class PrometheusScrapeEndpointHandler(
config: PrometheusProperties,
registry: PrometheusRegistry
) : Handler {
private val scrapeHandler = PrometheusScrapeHandler(config, registry)

override fun invoke(call: HttpCall) {
scrapeHandler.handleRequest(HttpExchangeAdapter(call.rawRequest, call.rawResponse))
}
}
63 changes: 63 additions & 0 deletions metrics/src/test/kotlin/kotlet/metrics/MetricsScrapeUnitTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package kotlet.metrics

import io.prometheus.metrics.model.registry.PrometheusRegistry
import kotlet.HttpMethod
import kotlet.Kotlet
import kotlet.mocks.Mocks
import kotlet.prometheus.PrometheusMetricsCollector
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertEquals

class MetricsScrapeUnitTest {
@Test
fun testEndpoint() {
val clock = Clock.fixed(Instant.now(), ZoneId.of("UTC"))
val collector = PrometheusMetricsCollector(PrometheusRegistry.defaultRegistry, clock)

val route = Kotlet.routing {
installMetrics(collector)

get("/", {})

installMetricsScrape {
path = "/metrics"
}
}

val servlet = Kotlet.servlet(listOf(route))

val getCall = Mocks.httpCall(
method = HttpMethod.GET,
routePath = "/"
)
servlet.service(getCall.rawRequest, getCall.rawResponse)

val metricsCall = Mocks.httpCall(
method = HttpMethod.GET,
routePath = "/metrics"
)
servlet.service(metricsCall.rawRequest, metricsCall.rawResponse)

val body = metricsCall.responseData.toString(Charsets.UTF_8)
val expected = """
# HELP kotlet_http_requests_total Total number of HTTP requests
# TYPE kotlet_http_requests_total counter
kotlet_http_requests_total{method="GET",path="/",status="200"} 1.0
# HELP kotlet_http_requests_duration_seconds Duration of HTTP requests in seconds
# TYPE kotlet_http_requests_duration_seconds summary
kotlet_http_requests_duration_seconds{method="GET",path="/",status="200",quantile="0.5"} 0.0
kotlet_http_requests_duration_seconds{method="GET",path="/",status="200",quantile="0.9"} 0.0
kotlet_http_requests_duration_seconds{method="GET",path="/",status="200",quantile="0.95"} 0.0
kotlet_http_requests_duration_seconds{method="GET",path="/",status="200",quantile="0.99"} 0.0
kotlet_http_requests_duration_seconds_count{method="GET",path="/",status="200"} 1
kotlet_http_requests_duration_seconds_sum{method="GET",path="/",status="200"} 0.0
""".trimIndent()

assertEquals(expected, body)
}

}
9 changes: 9 additions & 0 deletions mocks/src/main/kotlin/kotlet/mocks/http/MockHttpCall.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@ class MockHttpCall(
val responseData: ByteArray
get() = responseStream.toByteArray()

val responseHeaders: MutableMap<String, String> = mutableMapOf()

init {
rawRequest = createHttpRequestMock(
path = routePath,
methodName = httpMethod.name,
async = async,
headers = headers,
Expand All @@ -48,6 +51,9 @@ class MockHttpCall(
val responseHeaders = mutableMapOf<String, String>()
rawResponse = mockk {
every { outputStream } returns ByteArrayServletOutputStream(responseStream)
every { setHeader(any(), any()) } answers {
responseHeaders[this.firstArg()] = this.secondArg()
}

// contentTypeField is a private field, so we need to use a setter to set it
every { contentType = any() } answers { contentTypeField = this.firstArg() }
Expand All @@ -72,6 +78,7 @@ class MockHttpCall(
}

private fun createHttpRequestMock(
path: String,
methodName: String,
async: Boolean,
headers: Map<String, String>,
Expand All @@ -80,6 +87,8 @@ private fun createHttpRequestMock(
val attributes = mutableMapOf<String, Any>()

return mockk {
every { requestURI } returns path
every { queryString } returns ""
every { inputStream } returns ByteArrayServletInputStream(requestData)
every { getHeader(any()) } answers {
headers[this.firstArg()]
Expand Down

0 comments on commit a724899

Please sign in to comment.