From 2e0c9ffdc9f6f8a1bd071b6febb0712a0d5fc46e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 10 Jun 2026 15:08:45 +0200 Subject: [PATCH 1/6] fix: add serialization bindings for KeyValue --- build.sbt | 2 +- kv/src/main/resources/reference.conf | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 kv/src/main/resources/reference.conf diff --git a/build.sbt b/build.sbt index d37df4e..eef2abf 100644 --- a/build.sbt +++ b/build.sbt @@ -16,7 +16,7 @@ ThisBuild / organization := "app.softnetwork" name := "generic-persistence-api" -ThisBuild / version := "0.8.5" +ThisBuild / version := "0.8.6" lazy val moduleSettings = Seq( crossScalaVersions := Seq(scala212, scala213), diff --git a/kv/src/main/resources/reference.conf b/kv/src/main/resources/reference.conf new file mode 100644 index 0000000..42b02cf --- /dev/null +++ b/kv/src/main/resources/reference.conf @@ -0,0 +1,6 @@ +# KeyValue extends both ProtobufDomainObject (proto) and KvState/State (chill). +# Without this explicit binding, Akka finds multiple serializers and may pick +# the wrong one, causing snapshot deserialization failures. +akka.actor.serialization-bindings { + "app.softnetwork.kv.model.KeyValue" = proto +} From 734cc41f6adaeca762ab33f6e7917eb47bc7eaaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 10 Jun 2026 15:13:00 +0200 Subject: [PATCH 2/6] feat: add HTTP metrics recording to Prometheus for request latency and status --- project/Versions.scala | 4 ++ server/build.sbt | 7 ++- .../softnetwork/api/server/ApiRoutes.scala | 57 ++++++++++++------- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/project/Versions.scala b/project/Versions.scala index 2d2ea39..0cfa4e6 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -2,6 +2,10 @@ object Versions { val akka = "2.6.20" // TODO 2.6.20 -> 2.8.3 + // Prometheus client_java 1.x — HTTP-route metrics recorded into PrometheusRegistry.defaultRegistry + // (the shared registry a downstream /metrics endpoint serves). Story 13.6 Phase B. + val prometheus = "1.7.0" + val akkaHttp = "10.2.10" // TODO 10.2.10 -> 10.5.3 val akkaHttpJson4s = "1.39.2" //1.37.0 -> 1.39.2 diff --git a/server/build.sbt b/server/build.sbt index 55e655c..08c917d 100644 --- a/server/build.sbt +++ b/server/build.sbt @@ -19,4 +19,9 @@ val tapir = Seq( "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % Versions.tapir ) -libraryDependencies ++= akkaHttp ++ tapir +// Story 13.6 Phase B — record HTTP-route rate+latency into PrometheusRegistry.defaultRegistry. +val prometheus = Seq( + "io.prometheus" % "prometheus-metrics-core" % Versions.prometheus +) + +libraryDependencies ++= akkaHttp ++ tapir ++ prometheus diff --git a/server/src/main/scala/app/softnetwork/api/server/ApiRoutes.scala b/server/src/main/scala/app/softnetwork/api/server/ApiRoutes.scala index d98dc0b..08eecd7 100644 --- a/server/src/main/scala/app/softnetwork/api/server/ApiRoutes.scala +++ b/server/src/main/scala/app/softnetwork/api/server/ApiRoutes.scala @@ -52,28 +52,47 @@ trait ApiRoutes extends Directives with GrpcServices with DefaultComplete { ) ) + // Story 13.6 Phase B — record method / normalised-path / status + latency for every request into + // PrometheusRegistry.defaultRegistry. Wraps the WHOLE pipeline (outside handleRejections / + // handleExceptions) so mapResponse observes the final response, including rejection/exception ones. + private def withHttpMetrics(inner: Route): Route = + extractRequest { req => + val startNanos = System.nanoTime() + mapResponse { resp => + HttpMetrics.record( + req.method.value, + req.uri.path.toString, + resp.status.intValue(), + (System.nanoTime() - startNanos) / 1e9d + ) + resp + }(inner) + } + final def mainRoutes: ActorSystem[_] => Route = system => { val routes = concat((HealthCheckService :: apiRoutes(system)).map(_.route): _*) - handleRejections(rejectionHandler) { - handleExceptions(exceptionHandler) { - logRequestResult("RestAll") { - pathPrefix(config.ServerSettings.RootPath) { - Try( - respondWithHeaders(RawHeader("Api-Version", applicationVersion)) { - routes - } - ) match { - case Success(s) => s - case Failure(f) => - log.error(f.getMessage, f.getCause) - complete( - HttpResponse( - StatusCodes.InternalServerError, - entity = f.getMessage + withHttpMetrics { + handleRejections(rejectionHandler) { + handleExceptions(exceptionHandler) { + logRequestResult("RestAll") { + pathPrefix(config.ServerSettings.RootPath) { + Try( + respondWithHeaders(RawHeader("Api-Version", applicationVersion)) { + routes + } + ) match { + case Success(s) => s + case Failure(f) => + log.error(f.getMessage, f.getCause) + complete( + HttpResponse( + StatusCodes.InternalServerError, + entity = f.getMessage + ) ) - ) - } - } ~ grpcRoutes(system) + } + } ~ grpcRoutes(system) + } } } } From fd5f6d28a69f762f3fe1f2abd916b51458ecb352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 10 Jun 2026 15:26:10 +0200 Subject: [PATCH 3/6] feat: implement HTTP metrics recording for request rate and latency in Prometheus --- .../softnetwork/api/server/HttpMetrics.scala | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala diff --git a/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala b/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala new file mode 100644 index 0000000..dd55bec --- /dev/null +++ b/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala @@ -0,0 +1,47 @@ +package app.softnetwork.api.server + +import io.prometheus.metrics.core.metrics.{Counter, Histogram} + +/** Story 13.6 Phase B — HTTP request rate + latency, recorded into the global + * `PrometheusRegistry.defaultRegistry`. A downstream service's `/metrics` endpoint (served from the + * same default registry) exposes these series; the `service` label is added at scrape time by the + * ServiceMonitor relabeling (these are library-defined series with a fixed label set). + * + * `path` is normalised (id-like segments collapsed to `:id`) to bound cardinality, since the raw + * request path can embed UUIDs / numeric ids. + */ +object HttpMetrics { + + private val requests: Counter = Counter + .builder() + .name("http_requests") + .help("HTTP requests, by method / normalised path / status") + .labelNames("method", "path", "status") + .register() + + private val duration: Histogram = Histogram + .builder() + .name("http_request_duration_seconds") + .help("HTTP request duration in seconds, by method / normalised path") + .labelNames("method", "path") + .classicUpperBounds(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0) + .register() + + def record(method: String, path: String, status: Int, seconds: Double): Unit = { + val p = normalizePath(path) + requests.labelValues(method, p, status.toString).inc() + duration.labelValues(method, p).observe(seconds) + } + + /** Collapse id-like segments (UUID/hex >= 8 chars, or all-digits) to `:id`. */ + def normalizePath(path: String): String = + path + .split("/", -1) + .map { seg => + if (seg.isEmpty) seg + else if (seg.length >= 8 && seg.matches("[0-9a-fA-F-]+")) ":id" + else if (seg.matches("[0-9]+")) ":id" + else seg + } + .mkString("/") +} From 44cf7c02b2b373aa82eb90d49f6648b618182e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 10 Jun 2026 15:29:12 +0200 Subject: [PATCH 4/6] fix: improve formatting in HttpMetrics documentation --- .../main/scala/app/softnetwork/api/server/HttpMetrics.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala b/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala index dd55bec..f8d009d 100644 --- a/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala +++ b/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala @@ -3,9 +3,9 @@ package app.softnetwork.api.server import io.prometheus.metrics.core.metrics.{Counter, Histogram} /** Story 13.6 Phase B — HTTP request rate + latency, recorded into the global - * `PrometheusRegistry.defaultRegistry`. A downstream service's `/metrics` endpoint (served from the - * same default registry) exposes these series; the `service` label is added at scrape time by the - * ServiceMonitor relabeling (these are library-defined series with a fixed label set). + * `PrometheusRegistry.defaultRegistry`. A downstream service's `/metrics` endpoint (served from + * the same default registry) exposes these series; the `service` label is added at scrape time by + * the ServiceMonitor relabeling (these are library-defined series with a fixed label set). * * `path` is normalised (id-like segments collapsed to `:id`) to bound cardinality, since the raw * request path can embed UUIDs / numeric ids. From b714b68f37ca61de9443069bc10d5023cbecc3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 10 Jun 2026 15:50:30 +0200 Subject: [PATCH 5/6] fix: normalizePath uses String.matches(...) for every path segment, ... ... which recompiles the regex each call (and this runs on every request). This adds avoidable CPU overhead in a hot path; precompile the patterns once and reuse them. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../scala/app/softnetwork/api/server/HttpMetrics.scala | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala b/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala index f8d009d..477e3ca 100644 --- a/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala +++ b/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala @@ -33,14 +33,17 @@ object HttpMetrics { duration.labelValues(method, p).observe(seconds) } + private val HexLike = "^[0-9a-fA-F-]+$".r + private val DigitsOnly = "^[0-9]+$".r + /** Collapse id-like segments (UUID/hex >= 8 chars, or all-digits) to `:id`. */ def normalizePath(path: String): String = path .split("/", -1) .map { seg => if (seg.isEmpty) seg - else if (seg.length >= 8 && seg.matches("[0-9a-fA-F-]+")) ":id" - else if (seg.matches("[0-9]+")) ":id" + else if (seg.length >= 8 && HexLike.pattern.matcher(seg).matches()) ":id" + else if (DigitsOnly.pattern.matcher(seg).matches()) ":id" else seg } .mkString("/") From 1f03c7a79676f3dd32228b84d2934d445fa6f79f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Manciot?= Date: Wed, 10 Jun 2026 16:38:49 +0200 Subject: [PATCH 6/6] feat: implement HttpMetrics directive for request tracking and latency recording --- server/build.sbt | 10 ++- .../softnetwork/api/server/ApiRoutes.scala | 22 +---- .../softnetwork/api/server/HttpMetrics.scala | 20 +++++ .../api/server/HttpMetricsSpec.scala | 83 +++++++++++++++++++ 4 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 server/src/test/scala/app/softnetwork/api/server/HttpMetricsSpec.scala diff --git a/server/build.sbt b/server/build.sbt index 08c917d..5250a2d 100644 --- a/server/build.sbt +++ b/server/build.sbt @@ -24,4 +24,12 @@ val prometheus = Seq( "io.prometheus" % "prometheus-metrics-core" % Versions.prometheus ) -libraryDependencies ++= akkaHttp ++ tapir ++ prometheus +// Route-level test for the HttpMetrics directive (akka-http-testkit + text exposition to assert +// registry samples). Test-scope only. +val httpMetricsTest = Seq( + "com.typesafe.akka" %% "akka-http-testkit" % Versions.akkaHttp % Test, + "io.prometheus" % "prometheus-metrics-exposition-textformats" % Versions.prometheus % Test, + "org.scalatest" %% "scalatest" % Versions.scalatest % Test +) + +libraryDependencies ++= akkaHttp ++ tapir ++ prometheus ++ httpMetricsTest diff --git a/server/src/main/scala/app/softnetwork/api/server/ApiRoutes.scala b/server/src/main/scala/app/softnetwork/api/server/ApiRoutes.scala index 08eecd7..5f9acc5 100644 --- a/server/src/main/scala/app/softnetwork/api/server/ApiRoutes.scala +++ b/server/src/main/scala/app/softnetwork/api/server/ApiRoutes.scala @@ -52,26 +52,12 @@ trait ApiRoutes extends Directives with GrpcServices with DefaultComplete { ) ) - // Story 13.6 Phase B — record method / normalised-path / status + latency for every request into - // PrometheusRegistry.defaultRegistry. Wraps the WHOLE pipeline (outside handleRejections / - // handleExceptions) so mapResponse observes the final response, including rejection/exception ones. - private def withHttpMetrics(inner: Route): Route = - extractRequest { req => - val startNanos = System.nanoTime() - mapResponse { resp => - HttpMetrics.record( - req.method.value, - req.uri.path.toString, - resp.status.intValue(), - (System.nanoTime() - startNanos) / 1e9d - ) - resp - }(inner) - } - final def mainRoutes: ActorSystem[_] => Route = system => { val routes = concat((HealthCheckService :: apiRoutes(system)).map(_.route): _*) - withHttpMetrics { + // Story 13.6 Phase B — record method / normalised-path / status + latency for every request into + // PrometheusRegistry.defaultRegistry. Wraps the WHOLE pipeline (outside handleRejections / + // handleExceptions) so the final response — rejection/exception ones included — is observed. + HttpMetrics.withMetrics { handleRejections(rejectionHandler) { handleExceptions(exceptionHandler) { logRequestResult("RestAll") { diff --git a/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala b/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala index 477e3ca..20ab965 100644 --- a/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala +++ b/server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala @@ -1,5 +1,7 @@ package app.softnetwork.api.server +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route import io.prometheus.metrics.core.metrics.{Counter, Histogram} /** Story 13.6 Phase B — HTTP request rate + latency, recorded into the global @@ -33,6 +35,24 @@ object HttpMetrics { duration.labelValues(method, p).observe(seconds) } + /** akka-http directive: times the request and records method / normalised-path / status + latency + * when the inner route completes. Wrap it OUTSIDE rejection/exception handling so `mapResponse` + * observes the FINAL response (rejection- and exception-derived responses included). + */ + def withMetrics(inner: Route): Route = + extractRequest { req => + val startNanos = System.nanoTime() + mapResponse { resp => + record( + req.method.value, + req.uri.path.toString, + resp.status.intValue(), + (System.nanoTime() - startNanos) / 1e9d + ) + resp + }(inner) + } + private val HexLike = "^[0-9a-fA-F-]+$".r private val DigitsOnly = "^[0-9]+$".r diff --git a/server/src/test/scala/app/softnetwork/api/server/HttpMetricsSpec.scala b/server/src/test/scala/app/softnetwork/api/server/HttpMetricsSpec.scala new file mode 100644 index 0000000..4334d6f --- /dev/null +++ b/server/src/test/scala/app/softnetwork/api/server/HttpMetricsSpec.scala @@ -0,0 +1,83 @@ +package app.softnetwork.api.server + +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.{Directives, ExceptionHandler, RejectionHandler, Route} +import akka.http.scaladsl.testkit.ScalatestRouteTest +import io.prometheus.metrics.expositionformats.PrometheusTextFormatWriter +import io.prometheus.metrics.model.registry.PrometheusRegistry +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.io.ByteArrayOutputStream + +/** Story 13.6 Phase B — proves the HttpMetrics directive emits request/latency samples into the + * default registry for normal, rejection and exception responses, and that `normalizePath` + * collapses id-like segments. + */ +class HttpMetricsSpec extends AnyWordSpec with Matchers with ScalatestRouteTest with Directives { + + // Mirror the ApiRoutes wrapping: metrics OUTSIDE rejection/exception handling so the final response + // (including the rejection-derived 404 and the exception-derived 500) is observed. + private val exceptionHandler = ExceptionHandler { case _: RuntimeException => + complete(StatusCodes.InternalServerError -> "boom") + } + + private val route: Route = + HttpMetrics.withMetrics { + handleRejections(RejectionHandler.default) { + handleExceptions(exceptionHandler) { + concat( + path("ping")(get(complete("pong"))), + path("licenses" / Segment)(id => get(complete(id))), + path("boom")(get(failWith(new RuntimeException("boom")))) + ) + } + } + } + + private def scrapeText(): String = { + val writer = PrometheusTextFormatWriter.builder().build() + val out = new ByteArrayOutputStream() + writer.write(out, PrometheusRegistry.defaultRegistry.scrape()) + out.toString("UTF-8") + } + + "HttpMetrics.normalizePath" should { + "collapse numeric and uuid/hex segments to :id" in { + HttpMetrics.normalizePath("/api/licenses/123") shouldBe "/api/licenses/:id" + HttpMetrics.normalizePath( + "/api/licenses/550e8400-e29b-41d4-a716-446655440000" + ) shouldBe "/api/licenses/:id" + } + "leave non-id segments untouched" in { + HttpMetrics.normalizePath("/api/healthcheck") shouldBe "/api/healthcheck" + HttpMetrics.normalizePath("/ping") shouldBe "/ping" + } + } + + "The HttpMetrics directive" should { + "record a 200, normalising an id segment in the path label" in { + Get("/ping") ~> route ~> check { status shouldBe StatusCodes.OK } + Get("/licenses/550e8400-e29b-41d4-a716-446655440000") ~> route ~> check { + status shouldBe StatusCodes.OK + } + val text = scrapeText() + text should include("""http_requests_total{method="GET",path="/ping",status="200"}""") + text should include("""http_requests_total{method="GET",path="/licenses/:id",status="200"}""") + // histogram observed too + text should include("""http_request_duration_seconds_count{method="GET",path="/ping"}""") + } + + "record a rejection-derived 404 response" in { + Get("/does-not-exist") ~> route ~> check { status shouldBe StatusCodes.NotFound } + scrapeText() should include( + """http_requests_total{method="GET",path="/does-not-exist",status="404"}""" + ) + } + + "record an exception-derived 500 response" in { + Get("/boom") ~> route ~> check { status shouldBe StatusCodes.InternalServerError } + scrapeText() should include("""http_requests_total{method="GET",path="/boom",status="500"}""") + } + } +}