|
| 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