Skip to content

Commit dc3dc8d

Browse files
authored
Merge pull request #8 from SOFTNETWORK-APP/feature/prometheusHttpMetrics
feat: add HTTP metrics recording to Prometheus for request latency and s…
2 parents ba4b358 + 1f03c7a commit dc3dc8d

7 files changed

Lines changed: 202 additions & 21 deletions

File tree

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ ThisBuild / organization := "app.softnetwork"
1616

1717
name := "generic-persistence-api"
1818

19-
ThisBuild / version := "0.8.5"
19+
ThisBuild / version := "0.8.6"
2020

2121
lazy val moduleSettings = Seq(
2222
crossScalaVersions := Seq(scala212, scala213),
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# KeyValue extends both ProtobufDomainObject (proto) and KvState/State (chill).
2+
# Without this explicit binding, Akka finds multiple serializers and may pick
3+
# the wrong one, causing snapshot deserialization failures.
4+
akka.actor.serialization-bindings {
5+
"app.softnetwork.kv.model.KeyValue" = proto
6+
}

project/Versions.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ object Versions {
22

33
val akka = "2.6.20" // TODO 2.6.20 -> 2.8.3
44

5+
// Prometheus client_java 1.x — HTTP-route metrics recorded into PrometheusRegistry.defaultRegistry
6+
// (the shared registry a downstream /metrics endpoint serves). Story 13.6 Phase B.
7+
val prometheus = "1.7.0"
8+
59
val akkaHttp = "10.2.10" // TODO 10.2.10 -> 10.5.3
610

711
val akkaHttpJson4s = "1.39.2" //1.37.0 -> 1.39.2

server/build.sbt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,17 @@ val tapir = Seq(
1919
"com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % Versions.tapir
2020
)
2121

22-
libraryDependencies ++= akkaHttp ++ tapir
22+
// Story 13.6 Phase B — record HTTP-route rate+latency into PrometheusRegistry.defaultRegistry.
23+
val prometheus = Seq(
24+
"io.prometheus" % "prometheus-metrics-core" % Versions.prometheus
25+
)
26+
27+
// Route-level test for the HttpMetrics directive (akka-http-testkit + text exposition to assert
28+
// registry samples). Test-scope only.
29+
val httpMetricsTest = Seq(
30+
"com.typesafe.akka" %% "akka-http-testkit" % Versions.akkaHttp % Test,
31+
"io.prometheus" % "prometheus-metrics-exposition-textformats" % Versions.prometheus % Test,
32+
"org.scalatest" %% "scalatest" % Versions.scalatest % Test
33+
)
34+
35+
libraryDependencies ++= akkaHttp ++ tapir ++ prometheus ++ httpMetricsTest

server/src/main/scala/app/softnetwork/api/server/ApiRoutes.scala

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -54,26 +54,31 @@ trait ApiRoutes extends Directives with GrpcServices with DefaultComplete {
5454

5555
final def mainRoutes: ActorSystem[_] => Route = system => {
5656
val routes = concat((HealthCheckService :: apiRoutes(system)).map(_.route): _*)
57-
handleRejections(rejectionHandler) {
58-
handleExceptions(exceptionHandler) {
59-
logRequestResult("RestAll") {
60-
pathPrefix(config.ServerSettings.RootPath) {
61-
Try(
62-
respondWithHeaders(RawHeader("Api-Version", applicationVersion)) {
63-
routes
64-
}
65-
) match {
66-
case Success(s) => s
67-
case Failure(f) =>
68-
log.error(f.getMessage, f.getCause)
69-
complete(
70-
HttpResponse(
71-
StatusCodes.InternalServerError,
72-
entity = f.getMessage
57+
// Story 13.6 Phase B — record method / normalised-path / status + latency for every request into
58+
// PrometheusRegistry.defaultRegistry. Wraps the WHOLE pipeline (outside handleRejections /
59+
// handleExceptions) so the final response — rejection/exception ones included — is observed.
60+
HttpMetrics.withMetrics {
61+
handleRejections(rejectionHandler) {
62+
handleExceptions(exceptionHandler) {
63+
logRequestResult("RestAll") {
64+
pathPrefix(config.ServerSettings.RootPath) {
65+
Try(
66+
respondWithHeaders(RawHeader("Api-Version", applicationVersion)) {
67+
routes
68+
}
69+
) match {
70+
case Success(s) => s
71+
case Failure(f) =>
72+
log.error(f.getMessage, f.getCause)
73+
complete(
74+
HttpResponse(
75+
StatusCodes.InternalServerError,
76+
entity = f.getMessage
77+
)
7378
)
74-
)
75-
}
76-
} ~ grpcRoutes(system)
79+
}
80+
} ~ grpcRoutes(system)
81+
}
7782
}
7883
}
7984
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package app.softnetwork.api.server
2+
3+
import akka.http.scaladsl.server.Directives._
4+
import akka.http.scaladsl.server.Route
5+
import io.prometheus.metrics.core.metrics.{Counter, Histogram}
6+
7+
/** Story 13.6 Phase B — HTTP request rate + latency, recorded into the global
8+
* `PrometheusRegistry.defaultRegistry`. A downstream service's `/metrics` endpoint (served from
9+
* the same default registry) exposes these series; the `service` label is added at scrape time by
10+
* the ServiceMonitor relabeling (these are library-defined series with a fixed label set).
11+
*
12+
* `path` is normalised (id-like segments collapsed to `:id`) to bound cardinality, since the raw
13+
* request path can embed UUIDs / numeric ids.
14+
*/
15+
object HttpMetrics {
16+
17+
private val requests: Counter = Counter
18+
.builder()
19+
.name("http_requests")
20+
.help("HTTP requests, by method / normalised path / status")
21+
.labelNames("method", "path", "status")
22+
.register()
23+
24+
private val duration: Histogram = Histogram
25+
.builder()
26+
.name("http_request_duration_seconds")
27+
.help("HTTP request duration in seconds, by method / normalised path")
28+
.labelNames("method", "path")
29+
.classicUpperBounds(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
30+
.register()
31+
32+
def record(method: String, path: String, status: Int, seconds: Double): Unit = {
33+
val p = normalizePath(path)
34+
requests.labelValues(method, p, status.toString).inc()
35+
duration.labelValues(method, p).observe(seconds)
36+
}
37+
38+
/** akka-http directive: times the request and records method / normalised-path / status + latency
39+
* when the inner route completes. Wrap it OUTSIDE rejection/exception handling so `mapResponse`
40+
* observes the FINAL response (rejection- and exception-derived responses included).
41+
*/
42+
def withMetrics(inner: Route): Route =
43+
extractRequest { req =>
44+
val startNanos = System.nanoTime()
45+
mapResponse { resp =>
46+
record(
47+
req.method.value,
48+
req.uri.path.toString,
49+
resp.status.intValue(),
50+
(System.nanoTime() - startNanos) / 1e9d
51+
)
52+
resp
53+
}(inner)
54+
}
55+
56+
private val HexLike = "^[0-9a-fA-F-]+$".r
57+
private val DigitsOnly = "^[0-9]+$".r
58+
59+
/** Collapse id-like segments (UUID/hex >= 8 chars, or all-digits) to `:id`. */
60+
def normalizePath(path: String): String =
61+
path
62+
.split("/", -1)
63+
.map { seg =>
64+
if (seg.isEmpty) seg
65+
else if (seg.length >= 8 && HexLike.pattern.matcher(seg).matches()) ":id"
66+
else if (DigitsOnly.pattern.matcher(seg).matches()) ":id"
67+
else seg
68+
}
69+
.mkString("/")
70+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package app.softnetwork.api.server
2+
3+
import akka.http.scaladsl.model.StatusCodes
4+
import akka.http.scaladsl.server.{Directives, ExceptionHandler, RejectionHandler, Route}
5+
import akka.http.scaladsl.testkit.ScalatestRouteTest
6+
import io.prometheus.metrics.expositionformats.PrometheusTextFormatWriter
7+
import io.prometheus.metrics.model.registry.PrometheusRegistry
8+
import org.scalatest.matchers.should.Matchers
9+
import org.scalatest.wordspec.AnyWordSpec
10+
11+
import java.io.ByteArrayOutputStream
12+
13+
/** Story 13.6 Phase B — proves the HttpMetrics directive emits request/latency samples into the
14+
* default registry for normal, rejection and exception responses, and that `normalizePath`
15+
* collapses id-like segments.
16+
*/
17+
class HttpMetricsSpec extends AnyWordSpec with Matchers with ScalatestRouteTest with Directives {
18+
19+
// Mirror the ApiRoutes wrapping: metrics OUTSIDE rejection/exception handling so the final response
20+
// (including the rejection-derived 404 and the exception-derived 500) is observed.
21+
private val exceptionHandler = ExceptionHandler { case _: RuntimeException =>
22+
complete(StatusCodes.InternalServerError -> "boom")
23+
}
24+
25+
private val route: Route =
26+
HttpMetrics.withMetrics {
27+
handleRejections(RejectionHandler.default) {
28+
handleExceptions(exceptionHandler) {
29+
concat(
30+
path("ping")(get(complete("pong"))),
31+
path("licenses" / Segment)(id => get(complete(id))),
32+
path("boom")(get(failWith(new RuntimeException("boom"))))
33+
)
34+
}
35+
}
36+
}
37+
38+
private def scrapeText(): String = {
39+
val writer = PrometheusTextFormatWriter.builder().build()
40+
val out = new ByteArrayOutputStream()
41+
writer.write(out, PrometheusRegistry.defaultRegistry.scrape())
42+
out.toString("UTF-8")
43+
}
44+
45+
"HttpMetrics.normalizePath" should {
46+
"collapse numeric and uuid/hex segments to :id" in {
47+
HttpMetrics.normalizePath("/api/licenses/123") shouldBe "/api/licenses/:id"
48+
HttpMetrics.normalizePath(
49+
"/api/licenses/550e8400-e29b-41d4-a716-446655440000"
50+
) shouldBe "/api/licenses/:id"
51+
}
52+
"leave non-id segments untouched" in {
53+
HttpMetrics.normalizePath("/api/healthcheck") shouldBe "/api/healthcheck"
54+
HttpMetrics.normalizePath("/ping") shouldBe "/ping"
55+
}
56+
}
57+
58+
"The HttpMetrics directive" should {
59+
"record a 200, normalising an id segment in the path label" in {
60+
Get("/ping") ~> route ~> check { status shouldBe StatusCodes.OK }
61+
Get("/licenses/550e8400-e29b-41d4-a716-446655440000") ~> route ~> check {
62+
status shouldBe StatusCodes.OK
63+
}
64+
val text = scrapeText()
65+
text should include("""http_requests_total{method="GET",path="/ping",status="200"}""")
66+
text should include("""http_requests_total{method="GET",path="/licenses/:id",status="200"}""")
67+
// histogram observed too
68+
text should include("""http_request_duration_seconds_count{method="GET",path="/ping"}""")
69+
}
70+
71+
"record a rejection-derived 404 response" in {
72+
Get("/does-not-exist") ~> route ~> check { status shouldBe StatusCodes.NotFound }
73+
scrapeText() should include(
74+
"""http_requests_total{method="GET",path="/does-not-exist",status="404"}"""
75+
)
76+
}
77+
78+
"record an exception-derived 500 response" in {
79+
Get("/boom") ~> route ~> check { status shouldBe StatusCodes.InternalServerError }
80+
scrapeText() should include("""http_requests_total{method="GET",path="/boom",status="500"}""")
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)