Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions ktor-server/ktor-server-plugins/ktor-server-health-check/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# ktor-server-health-check

A Ktor server plugin that provides health check endpoints for Kubernetes readiness and liveness probes (and any other orchestration system that polls HTTP health endpoints).

## Motivation

Every production Ktor application deployed to Kubernetes needs health and readiness endpoints. Currently, developers write these manually every time. This plugin provides a declarative DSL - similar to Spring Actuator, Micronaut Health, or Quarkus SmallRye Health - so that health endpoints are consistent, correct, and require minimal boilerplate.

## Installation

Add the dependency to your `build.gradle.kts`:

```kotlin
dependencies {
implementation("io.ktor:ktor-server-health-check:$ktor_version")
}
```

## Quick Start

```kotlin
import io.ktor.server.application.*
import io.ktor.server.plugins.healthcheck.*

fun Application.module() {
install(HealthCheck) {
readiness("/ready") {
check("database") { dataSource.connection.isValid(1) }
check("redis") { redisClient.ping(); true }
}
liveness("/health") {
check("memory") { Runtime.getRuntime().freeMemory() > 10_000_000 }
}
}
}
```

## DSL Reference

### `readiness(path) { ... }`

Configures a readiness endpoint. Readiness checks determine whether the application is ready to serve traffic. Failed readiness checks cause Kubernetes to stop routing requests to the pod.

### `liveness(path) { ... }`

Configures a liveness endpoint. Liveness checks determine whether the application is still running. Failed liveness checks cause Kubernetes to restart the container.

### `check(name) { ... }`

Adds a named health check within a readiness or liveness block. The lambda should return `true` for healthy, `false` for unhealthy. If the lambda throws an exception, the check is treated as unhealthy and the exception message is included in the response.

## Response Format

The plugin responds with `application/json` and uses standard HTTP status codes:

| Condition | HTTP Status | `status` field |
|---|---|---|
| All checks pass | 200 OK | `UP` |
| Any check fails | 503 Service Unavailable | `DOWN` |

### All healthy

```json
{
"status": "UP",
"checks": [
{ "name": "database", "status": "UP" },
{ "name": "redis", "status": "UP" }
]
}
```

### One or more unhealthy

```json
{
"status": "DOWN",
"checks": [
{ "name": "database", "status": "UP" },
{ "name": "redis", "status": "DOWN", "error": "Connection refused" }
]
}
```

## Kubernetes Configuration

```yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
```

## Design Decisions

- **Concurrent checks**: All checks within an endpoint run concurrently via `coroutineScope` + `async`, minimizing probe latency when multiple I/O-bound checks are configured.
- **No external dependencies**: JSON responses are built with `StringBuilder` - no serialization library required. The plugin does not require `ContentNegotiation`.
- **`onCall` interception**: The plugin intercepts at the call level (before routing), matching configured paths directly. This means health endpoints work even without installing `Routing`.
- **GET-only**: Only `GET` requests are handled, matching the HTTP method used by Kubernetes probes and load balancers.
- **Multiplatform**: The plugin is implemented in `commonMain` and works on all Ktor server targets (JVM, Native, JS).
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

description = "Ktor server health check and readiness/liveness endpoints plugin"

plugins {
id("ktorbuild.project.server-plugin")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.server.plugins.healthcheck

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import kotlinx.coroutines.*

/**
* A plugin that provides health check endpoints for Kubernetes readiness and liveness probes.
*
* Intercepts GET requests matching configured paths and responds with a JSON health status.
* All checks within an endpoint run concurrently. The overall status is UP only when every
* individual check reports healthy.
*
* Response format:
* ```json
* {
* "status": "UP",
* "checks": [
* { "name": "database", "status": "UP" },
* { "name": "redis", "status": "DOWN", "error": "Connection refused" }
* ]
* }
* ```
*
* - HTTP 200 when all checks pass (status: UP)
* - HTTP 503 when any check fails (status: DOWN)
*
* Example:
* ```kotlin
* install(HealthCheck) {
* readiness("/ready") {
* check("database") { dataSource.connection.isValid(1) }
* check("redis") { redisClient.ping(); true }
* }
* liveness("/health") {
* check("memory") { Runtime.getRuntime().freeMemory() > threshold }
* }
* }
* ```
*/
public val HealthCheck: ApplicationPlugin<HealthCheckConfig> =
createApplicationPlugin("HealthCheck", ::HealthCheckConfig) {
val endpoints = pluginConfig.endpoints.toList()

onCall { call ->
if (call.response.isCommitted) return@onCall
if (call.request.httpMethod != HttpMethod.Get) return@onCall

val path = call.request.path()
val endpoint = endpoints.find { it.path == path } ?: return@onCall

val results = coroutineScope {
endpoint.checks.map { namedCheck ->
async { evaluateCheck(namedCheck) }
}.awaitAll()
}

val overallUp = results.all { it.status == CheckStatus.UP }
val httpStatus = if (overallUp) HttpStatusCode.OK else HttpStatusCode.ServiceUnavailable

call.respondText(
buildHealthResponse(overallUp, results),
ContentType.Application.Json,
httpStatus
)
}
}

private suspend fun evaluateCheck(namedCheck: NamedCheck): CheckResult =
try {
val healthy = namedCheck.check()
CheckResult(namedCheck.name, if (healthy) CheckStatus.UP else CheckStatus.DOWN)
} catch (cause: CancellationException) {
throw cause
} catch (cause: Exception) {
CheckResult(namedCheck.name, CheckStatus.DOWN, cause.message ?: "Health Check Failed")
}

internal enum class CheckStatus { UP, DOWN }

internal class CheckResult(val name: String, val status: CheckStatus, val error: String? = null)

private fun buildHealthResponse(overallUp: Boolean, results: List<CheckResult>): String = buildString {
append("{\"status\":\"")
append(if (overallUp) "UP" else "DOWN")
append("\",\"checks\":[")
results.forEachIndexed { index, result ->
if (index > 0) append(',')
append("{\"name\":\"")
append(result.name.escapeJson())
append("\",\"status\":\"")
append(result.status.name)
append('"')
if (result.error != null) {
append(",\"error\":\"")
append(result.error.escapeJson())
append('"')
}
append('}')
}
append("]}")
}

private fun String.escapeJson(): String = buildString(length) {
for (ch in this@escapeJson) {
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\b' -> append("\\b")
'\u000C' -> append("\\f")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> if (ch.code < 0x20) {
append("\\u")
append(ch.code.toString(16).padStart(4, '0'))
} else {
append(ch)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.server.plugins.healthcheck

import io.ktor.server.application.*
import io.ktor.utils.io.*

/**
* Configuration for the [HealthCheck] plugin.
*
* Use [readiness] and [liveness] to define health check endpoints with named checks.
*
* Example:
* ```kotlin
* install(HealthCheck) {
* readiness("/ready") {
* check("database") { dataSource.connection.isValid(1) }
* }
* liveness("/health") {
* check("heartbeat") { true }
* }
* }
* ```
*/
@KtorDsl
public class HealthCheckConfig {
internal val endpoints = mutableListOf<HealthCheckEndpoint>()

private fun addEndpoint(path: String, block: HealthCheckBuilder.() -> Unit) {
val normalizedPath = path.ensureLeadingSlash()
require(endpoints.none { it.path == normalizedPath }) {
"Health check endpoint path '$normalizedPath' is already configured"
}
endpoints += HealthCheckEndpoint(normalizedPath, HealthCheckBuilder().apply(block).checks.toList())
}

/**
* Configures a readiness endpoint at the given [path].
*
* Readiness checks determine whether the application is ready to serve requests.
* Failed readiness checks prevent Kubernetes from routing traffic to the pod.
*
* @param path the URL path for the readiness endpoint (e.g., "/ready")
* @param block builder for adding individual health checks
*/
public fun readiness(path: String, block: HealthCheckBuilder.() -> Unit) {
addEndpoint(path, block)
}

/**
* Configures a liveness endpoint at the given [path].
*
* Liveness checks determine whether the application is still running.
* Failed liveness checks cause Kubernetes to restart the container.
*
* @param path the URL path for the liveness endpoint (e.g., "/health")
* @param block builder for adding individual health checks
*/
public fun liveness(path: String, block: HealthCheckBuilder.() -> Unit) {
addEndpoint(path, block)
}
}

/**
* Builder for configuring individual health checks within a readiness or liveness endpoint.
*/
@KtorDsl
public class HealthCheckBuilder {
internal val checks = mutableListOf<NamedCheck>()

/**
* Adds a named health check.
*
* The [block] should return `true` if the component is healthy, `false` if unhealthy.
* If [block] throws an exception, the check is treated as unhealthy and the exception
* message is included in the JSON response.
*
* @param name a human-readable identifier for this check (e.g., "database", "redis")
* @param block a suspend function returning `true` for healthy, `false` for unhealthy
*/
public fun check(name: String, block: suspend () -> Boolean) {
checks += NamedCheck(name, block)
}
}
Comment on lines +28 to +86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add the module's /api/ signatures for this new public DSL.

This file introduces public API (HealthCheckConfig, HealthCheckBuilder), and HealthCheck.kt adds the public HealthCheck plugin entry point, but the PR doesn't include the new module's ABI dump under /api/. That leaves the public surface untracked and will usually fail the legacy ABI checks.

As per coding guidelines: "All public/protected API changes must be tracked in /api/ directories within modules using ABI validation" and "Run ./gradlew :module-name:updateLegacyAbi to update ABI signature files after public/protected API changes".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ktor-server/ktor-server-plugins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt`
around lines 27 - 77, This change introduced new public API types
(HealthCheckConfig, HealthCheckBuilder and the public HealthCheck plugin entry
point) but the module’s ABI signatures under the module's /api/ directory
weren’t updated; run the module's Gradle ABI update (./gradlew
:<module-name>:updateLegacyAbi) to regenerate the API signature files, verify
the new entries for HealthCheckConfig, HealthCheckBuilder and the HealthCheck
plugin are present in the /api/ output, and commit those generated signature
files so the legacy ABI checks include the new public DSL surface.


internal class NamedCheck(val name: String, val check: suspend () -> Boolean)

internal class HealthCheckEndpoint(val path: String, val checks: List<NamedCheck>)

private fun String.ensureLeadingSlash(): String = if (startsWith("/")) this else "/$this"
Loading