-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add HTTP metrics recording to Prometheus for request latency and s… #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2e0c9ff
fix: add serialization bindings for KeyValue
fupelaqu 734cc41
feat: add HTTP metrics recording to Prometheus for request latency an…
fupelaqu fd5f6d2
feat: implement HTTP metrics recording for request rate and latency i…
fupelaqu 44cf7c0
fix: improve formatting in HttpMetrics documentation
fupelaqu b714b68
fix: normalizePath uses String.matches(...) for every path segment, ...
fupelaqu 1f03c7a
feat: implement HttpMetrics directive for request tracking and latenc…
fupelaqu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
70 changes: 70 additions & 0 deletions
70
server/src/main/scala/app/softnetwork/api/server/HttpMetrics.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| 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 | ||
| * `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) | ||
| } | ||
|
|
||
| /** 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 | ||
|
|
||
| /** 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 && HexLike.pattern.matcher(seg).matches()) ":id" | ||
| else if (DigitsOnly.pattern.matcher(seg).matches()) ":id" | ||
| else seg | ||
| } | ||
| .mkString("/") | ||
| } | ||
83 changes: 83 additions & 0 deletions
83
server/src/test/scala/app/softnetwork/api/server/HttpMetricsSpec.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"}""") | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.