From 8cd8664e9c6a43ab26cb3f62a12ff3b10b4251fc Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 26 Mar 2026 23:08:45 +0100 Subject: [PATCH 1/2] feat(metrics): replace JSON metrics with Prometheus exposition format Add Ktor MicrometerMetrics plugin with PrometheusMeterRegistry exposing two custom gauges (shoppimo_websocket_connections_total and shoppimo_shopping_lists_total) alongside JVM/HTTP metrics at /api/metrics. Remove unused MetricsResponse and MemoryInfo data classes. --- backend/build.gradle.kts | 4 ++ .../kotlin/com/shoppinglist/Application.kt | 1 + .../com/shoppinglist/models/HealthResponse.kt | 17 ----- .../com/shoppinglist/plugins/Metrics.kt | 62 +++++++++++++++++++ .../com/shoppinglist/plugins/Routing.kt | 36 +---------- 5 files changed, 69 insertions(+), 51 deletions(-) create mode 100644 backend/src/main/kotlin/com/shoppinglist/plugins/Metrics.kt diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index dae76f3..49fbe8f 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -57,6 +57,10 @@ dependencies { implementation("com.zaxxer:HikariCP:$hikaricp_version") implementation("org.postgresql:postgresql:$postgresql_version") + // Metrics + implementation("io.ktor:ktor-server-metrics-micrometer") + implementation("io.micrometer:micrometer-registry-prometheus:1.11.0") + // Logging implementation("ch.qos.logback:logback-classic:$logback_version") diff --git a/backend/src/main/kotlin/com/shoppinglist/Application.kt b/backend/src/main/kotlin/com/shoppinglist/Application.kt index 6bfe716..6f02ec4 100644 --- a/backend/src/main/kotlin/com/shoppinglist/Application.kt +++ b/backend/src/main/kotlin/com/shoppinglist/Application.kt @@ -20,6 +20,7 @@ fun Application.module() { DatabaseFactory.init() configureSerialization() configureCORS() + configureMetrics() val pushRepository = PushSubscriptionRepositoryImpl() val pushService = runCatching { diff --git a/backend/src/main/kotlin/com/shoppinglist/models/HealthResponse.kt b/backend/src/main/kotlin/com/shoppinglist/models/HealthResponse.kt index 0f89e4c..6ef4508 100644 --- a/backend/src/main/kotlin/com/shoppinglist/models/HealthResponse.kt +++ b/backend/src/main/kotlin/com/shoppinglist/models/HealthResponse.kt @@ -14,21 +14,4 @@ data class HealthResponse( data class ErrorResponse( val status: String, val message: String -) - -@Serializable -data class MemoryInfo( - val total: Long, - val free: Long, - val used: Long, - val max: Long -) - -@Serializable -data class MetricsResponse( - val timestamp: Long, - val uptime: Long, - val memory: MemoryInfo, - val threads: Int, - val database: String ) \ No newline at end of file diff --git a/backend/src/main/kotlin/com/shoppinglist/plugins/Metrics.kt b/backend/src/main/kotlin/com/shoppinglist/plugins/Metrics.kt new file mode 100644 index 0000000..3f4d615 --- /dev/null +++ b/backend/src/main/kotlin/com/shoppinglist/plugins/Metrics.kt @@ -0,0 +1,62 @@ +package com.shoppinglist.plugins + +import com.shoppinglist.database.ShoppingLists +import io.ktor.server.application.* +import io.ktor.server.metrics.micrometer.* +import io.micrometer.core.instrument.Gauge +import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics +import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics +import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics +import io.micrometer.core.instrument.binder.system.ProcessorMetrics +import io.micrometer.prometheus.PrometheusConfig +import io.micrometer.prometheus.PrometheusMeterRegistry +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import java.util.concurrent.atomic.AtomicLong + +lateinit var appMicrometerRegistry: PrometheusMeterRegistry + private set + +// Falls back to last known value when the DB query fails inside the gauge supplier +private val cachedListCount = AtomicLong(0) + +fun Application.configureMetrics() { + appMicrometerRegistry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT) + + install(MicrometerMetrics) { + registry = appMicrometerRegistry + meterBinders = listOf( + JvmMemoryMetrics(), + JvmGcMetrics(), + JvmThreadMetrics(), + ProcessorMetrics() + ) + } + + Gauge.builder("shoppimo_websocket_connections_total", this) { app -> + try { + val wsService = app.attributes.getOrNull(WebSocketServiceKey) + wsService?.getConnectionManager() + ?.getActiveListIds() + ?.sumOf { listId -> wsService.getConnectionManager().getConnectionCount(listId) } + ?.toDouble() + ?: 0.0 + } catch (_: Exception) { + 0.0 + } + } + .description("Total number of active WebSocket connections across all lists") + .register(appMicrometerRegistry) + + Gauge.builder("shoppimo_shopping_lists_total", cachedListCount) { cached -> + try { + val count = transaction { ShoppingLists.selectAll().count() } + cached.set(count) + count.toDouble() + } catch (_: Exception) { + cached.get().toDouble() + } + } + .description("Total number of shopping lists in the database") + .register(appMicrometerRegistry) +} diff --git a/backend/src/main/kotlin/com/shoppinglist/plugins/Routing.kt b/backend/src/main/kotlin/com/shoppinglist/plugins/Routing.kt index f269dd7..edc2e1e 100644 --- a/backend/src/main/kotlin/com/shoppinglist/plugins/Routing.kt +++ b/backend/src/main/kotlin/com/shoppinglist/plugins/Routing.kt @@ -8,8 +8,7 @@ import com.shoppinglist.routes.listRoutes import com.shoppinglist.routes.pushRoutes import com.shoppinglist.models.HealthResponse import com.shoppinglist.models.ErrorResponse -import com.shoppinglist.models.MetricsResponse -import com.shoppinglist.models.MemoryInfo + import com.shoppinglist.repository.PushSubscriptionRepository import com.shoppinglist.services.PushNotificationService @@ -52,38 +51,7 @@ fun Application.configureRouting( } get("/metrics") { - try { - val runtime = Runtime.getRuntime() - val memoryInfo = MemoryInfo( - total = runtime.totalMemory(), - free = runtime.freeMemory(), - used = runtime.totalMemory() - runtime.freeMemory(), - max = runtime.maxMemory() - ) - - val metrics = MetricsResponse( - timestamp = System.currentTimeMillis(), - uptime = java.lang.management.ManagementFactory.getRuntimeMXBean().uptime, - memory = memoryInfo, - threads = java.lang.management.ManagementFactory.getThreadMXBean().threadCount, - database = try { - com.shoppinglist.database.DatabaseFactory.testConnection() - "connected" - } catch (e: Exception) { - "disconnected" - } - ) - - call.respond(metrics) - } catch (e: Exception) { - call.respond( - status = HttpStatusCode.InternalServerError, - message = ErrorResponse( - status = "ERROR", - message = e.message ?: "Unknown error" - ) - ) - } + call.respond(appMicrometerRegistry.scrape()) } if (pushRepository != null) { From 55fac261d5a874cc0b107ffd6247e6acd9c05e8d Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 26 Mar 2026 23:09:53 +0100 Subject: [PATCH 2/2] docs: update CHANGELOG and README for Prometheus metrics feature --- CHANGELOG.md | 4 ++++ README.md | 2 ++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c85e23..ecd862a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Prometheus application metrics** — Replace JSON `/api/metrics` endpoint with Prometheus exposition format using Ktor MicrometerMetrics plugin; exposes `shoppimo_websocket_connections_total` (active WebSocket connections) and `shoppimo_shopping_lists_total` (total lists in database) alongside JVM and HTTP request metrics + ## [6.1.0] - 2026-03-26 ### Added diff --git a/README.md b/README.md index b3568d7..1c9c0f3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A real-time collaborative shopping list app. Create a list, share the link, and - **Dark Mode** — Light, dark, and system-preference theme toggle - **Smart Autocomplete** — Suggests items from your cross-list history as you type - **Push Notifications** — Opt-in Web Push alerts when list changes happen +- **Prometheus Metrics** — Application metrics (online users, list count, JVM, HTTP requests) exposed for Prometheus scraping ## Tech Stack @@ -126,6 +127,7 @@ For production deployment, see [DEPLOYMENT.md](DEPLOYMENT.md). | Method | Path | Description | |--------|------|-------------| | `GET` | `/api/health` | Health check | +| `GET` | `/api/metrics` | Prometheus metrics | | `POST` | `/api/lists` | Create a new list | | `GET` | `/api/lists/{id}` | Get a list | | `POST` | `/api/lists/{id}/items` | Add an item |