From 1fb7085e91992b3d0c6f8e96832df2e415a63791 Mon Sep 17 00:00:00 2001 From: Keir Lawson Date: Tue, 31 Oct 2023 10:07:12 +0000 Subject: [PATCH 1/5] Change package name, port http4s --- README.md | 10 +- build.sbt | 13 +- build/tag.sh | 39 ------ .../com/ovoenergy/meters4s/Reporter.scala | 2 +- .../scala/com/ovoenergy/meters4s/syntax.scala | 2 +- .../com/ovoenergy/meters4s/ReporterTest.scala | 2 +- .../ovoenergy/meters4s/datadog/DataDog.scala | 4 +- docs/README.md | 6 +- http4s/src/main/scala/Meters4s.scala | 124 ++++++++++++++++++ project/build.properties | 2 +- project/plugins.sbt | 8 +- .../meter4s/prometheus/Prometheus.scala | 4 +- .../ovoenergy/meters4s/statsd/StatsD.scala | 4 +- 13 files changed, 158 insertions(+), 62 deletions(-) delete mode 100755 build/tag.sh create mode 100644 http4s/src/main/scala/Meters4s.scala diff --git a/README.md b/README.md index 3cc8ee8..ae70924 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ For comprehensive API documentation check [the scaladoc](https://ovotech.github. A simple usage example for incrementing a counter, backed by a Micrometer `SimpleMeterRegistry`: ```scala -import com.ovoenergy.meters4s.{Reporter, MetricsConfig} +import meters4s.{Reporter, MetricsConfig} import cats.effect.IO val config = MetricsConfig() @@ -60,8 +60,8 @@ for { ### With Datadog ```scala -import com.ovoenergy.meters4s.{MetricsConfig, Reporter} -import com.ovoenergy.meters4s.datadog.{DataDog, DataDogConfig} +import meters4s.{MetricsConfig, Reporter} +import meters4s.datadog.{DataDog, DataDogConfig} import cats.effect.IO val datadog = @@ -81,8 +81,8 @@ import cats.effect._ import cats.effect.std.Console import cats.effect.syntax.all._ import cats.syntax.all._ -import com.ovoenergy.meter4s.prometheus._ -import com.ovoenergy.meters4s.{MetricsConfig, Reporter} +import meter4s.prometheus._ +import meters4s.{MetricsConfig, Reporter} import io.micrometer.core.instrument.binder.system.ProcessorMetrics import scala.concurrent.duration._ diff --git a/build.sbt b/build.sbt index 9053715..4283efa 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,6 @@ import ReleaseTransformations._ -lazy val additionalSupportedScalaVersions = List("2.13.10", "2.12.17") +lazy val additionalSupportedScalaVersions = List("2.13.12", "2.12.18") lazy val root = (project in file(".")) .settings( @@ -120,6 +120,17 @@ lazy val prometheus = project ) .dependsOn(core) +lazy val http4s = project + .settings( + name := "meters4s-http4s", + commonSettings, + publishSettings, + libraryDependencies ++= commonDependencies ++ Seq( + "org.http4s" %% "http4s-core" % "0.23.17", + ) + ) + .dependsOn(core) + lazy val docs = project .settings( commonSettings, diff --git a/build/tag.sh b/build/tag.sh deleted file mode 100755 index 172c406..0000000 --- a/build/tag.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash - -set -e - -echo 'Fetching tag from remote...' -git tag -l | xargs git tag -d -git fetch --tags - -# TODO Find a way to store the output -if ! git describe --exact-match 2>/dev/null; then - echo 'Not tag found...' - - last_tag=`git describe --abbrev=0 --tags` - current_version=${last_tag} - - echo "Current version ${current_version}" - - #replace . with space so can split into an array - current_version_parts=(${current_version//./ }) - - #get number parts and increase last one by 1 - current_version_major=${current_version_parts[0]} - current_version_minor=${current_version_parts[1]} - current_version_build=${current_version_parts[2]} - - next_version_build=$((current_version_build+1)) - next_version="$current_version_major.$current_version_minor.$next_version_build" - next_tag="${next_version}" - - echo "Tagging the current commit with ${next_tag}" - - git tag -a ${next_tag} -m "Release version "${next_version} - - echo "Pushing tag ${next_tag} to origin" - git push origin ${next_tag} - -else - echo 'Tag found, no tag will be add' -fi \ No newline at end of file diff --git a/core/src/main/scala/com/ovoenergy/meters4s/Reporter.scala b/core/src/main/scala/com/ovoenergy/meters4s/Reporter.scala index 40ffd89..74158df 100644 --- a/core/src/main/scala/com/ovoenergy/meters4s/Reporter.scala +++ b/core/src/main/scala/com/ovoenergy/meters4s/Reporter.scala @@ -1,4 +1,4 @@ -package com.ovoenergy.meters4s +package meters4s import cats.effect.Sync import cats.implicits._ diff --git a/core/src/main/scala/com/ovoenergy/meters4s/syntax.scala b/core/src/main/scala/com/ovoenergy/meters4s/syntax.scala index 41b8ca8..403610c 100644 --- a/core/src/main/scala/com/ovoenergy/meters4s/syntax.scala +++ b/core/src/main/scala/com/ovoenergy/meters4s/syntax.scala @@ -1,4 +1,4 @@ -package com.ovoenergy.meters4s +package meters4s import scala.concurrent.duration._ diff --git a/core/src/test/scala/com/ovoenergy/meters4s/ReporterTest.scala b/core/src/test/scala/com/ovoenergy/meters4s/ReporterTest.scala index e1b4900..b6f278d 100644 --- a/core/src/test/scala/com/ovoenergy/meters4s/ReporterTest.scala +++ b/core/src/test/scala/com/ovoenergy/meters4s/ReporterTest.scala @@ -1,4 +1,4 @@ -package com.ovoenergy.meters4s +package meters4s import io.micrometer.core.instrument.simple.SimpleMeterRegistry import cats.effect.IO diff --git a/datadog/src/main/scala/com/ovoenergy/meters4s/datadog/DataDog.scala b/datadog/src/main/scala/com/ovoenergy/meters4s/datadog/DataDog.scala index c96f5f3..c5687c5 100644 --- a/datadog/src/main/scala/com/ovoenergy/meters4s/datadog/DataDog.scala +++ b/datadog/src/main/scala/com/ovoenergy/meters4s/datadog/DataDog.scala @@ -1,8 +1,8 @@ -package com.ovoenergy.meters4s.datadog +package meters4s.datadog import cats.effect.{Async, Resource, Sync} import cats.implicits._ -import com.ovoenergy.meters4s.{MetricsConfig, Reporter} +import meters4s.{MetricsConfig, Reporter} import io.micrometer.core.instrument.MeterRegistry import io.micrometer.datadog.{ DatadogMeterRegistry, diff --git a/docs/README.md b/docs/README.md index 669974a..74e1bf8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,7 +44,7 @@ For comprehensive API documentation check [the scaladoc](https://ovotech.github. A simple usage example for incrementing a counter, backed by a Micrometer `SimpleMeterRegistry`: ```scala mdoc:silent -import com.ovoenergy.meters4s.{Reporter, MetricsConfig} +import meters4s.{Reporter, MetricsConfig} import cats.effect.IO import scala.concurrent.ExecutionContext.Implicits.global @@ -61,8 +61,8 @@ for { ### With Datadog ```scala mdoc:silent -import com.ovoenergy.meters4s.{MetricsConfig, Reporter} -import com.ovoenergy.meters4s.datadog.{DataDog, DataDogConfig} +import meters4s.{MetricsConfig, Reporter} +import meters4s.datadog.{DataDog, DataDogConfig} import cats.effect.IO val datadog = DataDog.createReporter[IO](DataDogConfig(apiKey = "1234"), MetricsConfig()) diff --git a/http4s/src/main/scala/Meters4s.scala b/http4s/src/main/scala/Meters4s.scala new file mode 100644 index 0000000..a4ec625 --- /dev/null +++ b/http4s/src/main/scala/Meters4s.scala @@ -0,0 +1,124 @@ +package meters4s.http4s + +import scala.concurrent.duration._ + +import cats.effect._ +import cats.syntax.all._ + +import org.http4s.metrics.TerminationType._ +import org.http4s.metrics.{MetricsOps, TerminationType} +import org.http4s.{Method, Status} +import meters4s.Reporter + +object Meters4s { + + private val TagsReg = """.*?\[([^\]]*)\]""".r + private val TagReg = """([^:]*)\s*:\s*(.*)""".r + + def apply[F[_]: Async]( + reporter: Reporter[F], + percentiles: Set[Double] = Set.empty + ): MetricsOps[F] = + new MetricsOps[F] { + + private def namespace(classifier: Option[String]): String = { + classifier + .map(_.takeWhile(_ != '[').trim) + .filter(_.nonEmpty) + .getOrElse("default") + } + + private def name(classifier: Option[String], key: String): String = + s"${namespace(classifier)}.$key" + + private def tags(classifier: Option[String]): Map[String, String] = { + classifier + .collect { + case TagsReg(tagsString) if tagsString.trim.nonEmpty => + tagsString + .split(",") + .collect { case TagReg(key, value) => + Map(key -> value) + } + .reduce(_ ++ _) + } + .getOrElse(Map.empty) + + } + + def increaseActiveRequests(classifier: Option[String]): F[Unit] = + reporter.gauge(name(classifier, "active-requests"), tags(classifier)).flatMap(_.increment) + + def decreaseActiveRequests(classifier: Option[String]): F[Unit] = + reporter.gauge(name(classifier, "active-requests"), tags(classifier)).flatMap(_.decrement) + + def recordHeadersTime( + method: Method, + elapsed: Long, + classifier: Option[String] + ): F[Unit] = + reporter + .timer( + name(classifier, "response-headers-time"), + tags(classifier) ++ methodTags(method), + percentiles + ) + .flatMap(_.record(elapsed.nanos)) + + def recordAbnormalTermination( + elapsed: Long, + terminationType: TerminationType, + classifier: Option[String] + ): F[Unit] = { + val terminationTags = terminationType match { + case Abnormal(_) => "termination" -> "abnormal" + case Error(_) => "termination" -> "error" + case Canceled => "termination" -> "cancelled" + case Timeout => "termination" -> "timeout" + } + + recordResponseTime( + classifier, + tags(classifier) ++ Map(terminationTags), + elapsed + ) + } + def recordTotalTime( + method: Method, + status: Status, + elapsed: Long, + classifier: Option[String] + ): F[Unit] = { + val statusTags = status.responseClass match { + case Status.Informational => "status-code" -> "1xx" + case Status.Successful => "status-code" -> "2xx" + case Status.Redirection => "status-code" -> "3xx" + case Status.ClientError => "status-code" -> "4xx" + case Status.ServerError => "status-code" -> "5xx" + } + val allTags = tags(classifier) ++ + Map("termination" -> "normal", statusTags) ++ + methodTags(method) + + recordResponseTime( + classifier, + allTags, + elapsed + ) + } + + private def recordResponseTime( + classifier: Option[String], + tags: Map[String, String], + elapsed: Long + ): F[Unit] = + reporter + .timer(name(classifier, "response-time"), tags, percentiles) + .flatMap(_.record(elapsed.nanos)) + + private def methodTags(method: Method): Map[String, String] = Map( + "method" -> method.name.toLowerCase + ) + + } +} diff --git a/project/build.properties b/project/build.properties index fdb2429..d415199 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.8.3 \ No newline at end of file +sbt.version=1.9.7 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index 94f1498..7de8daf 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,11 +1,11 @@ addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.1") addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.3.6") -addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3") -addSbtPlugin("com.typesafe.sbt" % "sbt-site" % "1.4.1") +addSbtPlugin("com.github.sbt" % "sbt-ghpages" % "0.8.0") +addSbtPlugin("com.github.sbt" % "sbt-site" % "1.5.0") addSbtPlugin("com.eed3si9n" % "sbt-unidoc" % "0.4.3") -addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.1.1") -addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.7.0") +// addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.1.1") +// addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.7.0") addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.13") addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.1.1") addSbtPlugin("com.github.sbt" % "sbt-release" % "1.1.0") diff --git a/prometheus/src/main/scala/com/ovoenergy/meter4s/prometheus/Prometheus.scala b/prometheus/src/main/scala/com/ovoenergy/meter4s/prometheus/Prometheus.scala index c233043..d2c07f8 100644 --- a/prometheus/src/main/scala/com/ovoenergy/meter4s/prometheus/Prometheus.scala +++ b/prometheus/src/main/scala/com/ovoenergy/meter4s/prometheus/Prometheus.scala @@ -14,9 +14,9 @@ * limitations under the License. */ -package com.ovoenergy.meter4s.prometheus +package meter4s.prometheus import cats.effect.{Async, Resource, Sync} -import com.ovoenergy.meters4s.{MetricsConfig, Reporter} +import meters4s.{MetricsConfig, Reporter} import io.micrometer.prometheus import io.micrometer.prometheus.{ PrometheusMeterRegistry, diff --git a/statsd/src/main/scala/com/ovoenergy/meters4s/statsd/StatsD.scala b/statsd/src/main/scala/com/ovoenergy/meters4s/statsd/StatsD.scala index 15c0164..e059ba3 100644 --- a/statsd/src/main/scala/com/ovoenergy/meters4s/statsd/StatsD.scala +++ b/statsd/src/main/scala/com/ovoenergy/meters4s/statsd/StatsD.scala @@ -1,7 +1,7 @@ -package com.ovoenergy.meters4s.statsd +package meters4s.statsd import cats.effect.{Resource, Sync, Async} -import com.ovoenergy.meters4s.{MetricsConfig, Reporter} +import meters4s.{MetricsConfig, Reporter} import io.micrometer.statsd.{StatsdConfig => MmStatsdConfig} import io.micrometer.core.instrument.MeterRegistry import scala.concurrent.duration.FiniteDuration From b031bc4174572f4b5443c6a59d77f247e78be8bc Mon Sep 17 00:00:00 2001 From: Keir Lawson Date: Tue, 31 Oct 2023 10:25:56 +0000 Subject: [PATCH 2/5] Port http4s tests --- build.sbt | 11 +- .../meters4s/http4s/ClientMetricsSuite.scala | 362 +++++++++++++++ .../meters4s/http4s/ServerMetricsSuite.scala | 434 ++++++++++++++++++ .../src/test/scala/meters4s/http4s/util.scala | 104 +++++ 4 files changed, 908 insertions(+), 3 deletions(-) create mode 100644 http4s/src/test/scala/meters4s/http4s/ClientMetricsSuite.scala create mode 100644 http4s/src/test/scala/meters4s/http4s/ServerMetricsSuite.scala create mode 100644 http4s/src/test/scala/meters4s/http4s/util.scala diff --git a/build.sbt b/build.sbt index 4283efa..1cbc2f1 100644 --- a/build.sbt +++ b/build.sbt @@ -1,5 +1,7 @@ import ReleaseTransformations._ +lazy val http4sVersion = "0.23.23" + lazy val additionalSupportedScalaVersions = List("2.13.12", "2.12.18") lazy val root = (project in file(".")) @@ -34,7 +36,7 @@ lazy val root = (project in file(".")) .enablePlugins(GhpagesPlugin) .enablePlugins(SiteScaladocPlugin) .enablePlugins(ScalaUnidocPlugin) - .aggregate(core, datadog, statsd, prometheus, docs) + .aggregate(core, datadog, statsd, prometheus, docs, http4s) lazy val commonSettings = Seq( organization := "com.ovoenergy", @@ -72,7 +74,7 @@ lazy val publishSettings = Seq( lazy val commonDependencies = Seq( "org.typelevel" %% "cats-core" % "2.9.0", "org.typelevel" %% "cats-effect" % "3.5.0", - "org.typelevel" %% "munit-cats-effect-3" % "1.0.7" % "test", + "org.typelevel" %% "munit-cats-effect" % "2.0.0-M3" % Test, "io.micrometer" % "micrometer-core" % "1.10.5", "org.scala-lang.modules" %% "scala-collection-compat" % "2.10.0", // See https://github.com/micrometer-metrics/micrometer/issues/1133#issuecomment-452434205 @@ -126,7 +128,10 @@ lazy val http4s = project commonSettings, publishSettings, libraryDependencies ++= commonDependencies ++ Seq( - "org.http4s" %% "http4s-core" % "0.23.17", + "org.http4s" %% "http4s-core" % http4sVersion, + "org.http4s" %% "http4s-dsl" % http4sVersion % Test, + "org.http4s" %% "http4s-server" % http4sVersion % Test, + "org.http4s" %% "http4s-client" % http4sVersion % Test, ) ) .dependsOn(core) diff --git a/http4s/src/test/scala/meters4s/http4s/ClientMetricsSuite.scala b/http4s/src/test/scala/meters4s/http4s/ClientMetricsSuite.scala new file mode 100644 index 0000000..5126bcd --- /dev/null +++ b/http4s/src/test/scala/meters4s/http4s/ClientMetricsSuite.scala @@ -0,0 +1,362 @@ +package meters4s.http4s + +import java.io.IOException +import java.util.concurrent.TimeoutException +import scala.concurrent.duration._ + +import cats.effect._ + +import org.http4s._ +import org.http4s.client._ +import org.http4s.client.middleware.Metrics +import org.http4s.dsl.io._ +import meters4s.http4s.util._ +import org.http4s.syntax.all._ +import meters4s.{MetricsConfig, Reporter} +import io.micrometer.core.instrument.search.MeterNotFoundException +import io.micrometer.core.instrument.{MeterRegistry, Tags} + +class ClientMetricsSuite extends munit.CatsEffectSuite { + + def resourcesWithClassifier(classifierF: Request[IO] => Option[String] = _ => None) = + ResourceFunFixture { + meterRegistryResource.evalMap { registry => + val config: MetricsConfig = MetricsConfig("client.") + Reporter.fromRegistry[IO](registry, config).map { reporter => + implicit val clock: Clock[IO] = FakeClock[IO] + + val client = Client.fromHttpApp[IO](HttpApp[IO](stub)) + val metrics = Meters4s[IO](reporter) + val meteredClient = Metrics[IO](metrics, classifierF)(client) + + (registry, meteredClient) + } + } + } + + val resources = resourcesWithClassifier() + + def testMetersFor( + registry: MeterRegistry, + method: String = "get", + statusCode: String = "2xx", + classifier: String = "default", + termination: String = "normal", + additionalTags: Tags = Tags.empty + ) = { + + // TODO test for non existence of classifier + + val allStatuses = List( + "2xx", + "3xx", + "4xx", + "5xx" + ) + + val allMethods = List( + "get", + "put", + "post", + "patch", + "delete", + "head", + "move", + "options", + "trace", + "connect", + "other" + ) + + val allTerminations = List( + "abnormal", + "error", + "timeout" + ) + + allStatuses.filter(_ != statusCode).foreach { x => + intercept[MeterNotFoundException] { + meterCount( + registry, + Timer(s"client.${classifier}.response-time", additionalTags and Tags.of("status-code", x)) + ) + } + } + + allMethods.filter(_ != method).foreach { x => + intercept[MeterNotFoundException] { + meterCount( + registry, + Timer(s"client.${classifier}.response-time", additionalTags and Tags.of("method", x)) + ) + } + + intercept[MeterNotFoundException] { + meterCount( + registry, + Timer( + s"client.${classifier}.response-headers-time", + additionalTags and Tags.of("method", x) + ) + ) + } + } + + allTerminations.filter(_ != termination).foreach { x => + intercept[MeterNotFoundException] { + meterCount( + registry, + Timer(s"client.${classifier}.response-time", additionalTags and Tags.of("termination", x)) + ) + } + } + + val responseTimeTags = if (termination != "normal") { + Tags.of("termination", termination) + } else { + Tags.of("status-code", statusCode, "method", method, "termination", termination) + } + + assertEquals( + meterCount( + registry, + Timer( + s"client.${classifier}.response-time", + additionalTags and responseTimeTags + ) + ), + 1L + ) + + if (termination == "normal") { + assertEquals( + meterMaxTime( + registry, + Timer( + s"client.${classifier}.response-time", + additionalTags and responseTimeTags + ) + ), + 100.milliseconds + ) + + assertEquals( + meterTotalTime( + registry, + Timer( + s"client.${classifier}.response-time", + additionalTags and responseTimeTags + ) + ), + 100.milliseconds + ) + + assertEquals( + meterCount( + registry, + Timer( + s"client.${classifier}.response-headers-time", + additionalTags and Tags.of("method", method) + ) + ), + 1L + ) + + assertEquals( + meterMaxTime( + registry, + Timer( + s"client.${classifier}.response-headers-time", + additionalTags and Tags.of("method", method) + ) + ), + 50.milliseconds + ) + + assertEquals( + meterTotalTime( + registry, + Timer( + s"client.${classifier}.response-headers-time", + additionalTags and Tags.of("method", method) + ) + ), + 50.milliseconds + ) + } + + assertEquals( + meterValue( + registry, + Gauge(s"client.${classifier}.active-requests", additionalTags) + ), + 0d + ) + } + + resources.test( + "Http client with a micrometer metrics middleware should register a 2xx response" + ) { case (registry, meteredClient) => + meteredClient + .statusFromString("/ok") + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry))) + } + + resources.test( + "Http client with a micrometer metrics middleware should register a 4xx response" + ) { case (registry, meteredClient) => + meteredClient + .statusFromString("/bad-request") + .assertEquals(Status.BadRequest) + .flatMap(_ => IO(testMetersFor(registry, statusCode = "4xx"))) + } + + resources.test( + "Http client with a micrometer metrics middleware should register a 5xx response" + ) { case (registry, meteredClient) => + meteredClient + .statusFromString("/internal-server-error") + .assertEquals(Status.InternalServerError) + .flatMap(_ => IO(testMetersFor(registry, statusCode = "5xx"))) + + } + + resources.test( + "Http client with a micrometer metrics middleware should register a GET request" + ) { case (registry, meteredClient) => + meteredClient + .statusFromString("/ok") + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "get"))) + } + + resources.test( + "Http client with a micrometer metrics middleware should register a POST request" + ) { case (registry, meteredClient) => + meteredClient + .status(Request[IO](POST, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "post"))) + } + + resources.test("Http client with a micrometer metrics middleware should register a PUT request") { + case (registry, meteredClient) => + meteredClient + .status(Request[IO](PUT, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "put"))) + + } + + resources.test( + "Http client with a micrometer metrics middleware should register a PATCH request" + ) { case (registry, meteredClient) => + meteredClient + .status(Request[IO](PATCH, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "patch"))) + } + + resources.test( + "Http client with a micrometer metrics middleware should register a DELETE request" + ) { case (registry, meteredClient) => + meteredClient + .status(Request[IO](DELETE, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "delete"))) + } + + resources.test( + "Http client with a micrometer metrics middleware should register a HEAD request" + ) { case (registry, meteredClient) => + meteredClient + .status(Request[IO](HEAD, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "head"))) + } + + resources.test( + "Http client with a micrometer metrics middleware should register a OPTIONS request" + ) { case (registry, meteredClient) => + meteredClient + .status(Request[IO](OPTIONS, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "options"))) + } + + resources.test("Http client with a micrometer metrics middleware should register an error") { + case (registry, meteredClient) => + meteredClient + .statusFromString("/error") + .intercept[IOException] + .flatMap(_ => IO(testMetersFor(registry, termination = "error"))) + } + + resources.test("Http client with a micrometer metrics middleware should register a timeout") { + case (registry, meteredClient) => + meteredClient + .statusFromString("/timeout") + .intercept[TimeoutException] + .flatMap(_ => IO(testMetersFor(registry, termination = "timeout"))) + } + + resourcesWithClassifier((_: Request[IO]) => Some("classifier")).test( + "Http client with a micrometer metrics middleware should use the provided request classifier" + ) { case (registry, meteredClient) => + meteredClient + .statusFromString("/ok") + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, classifier = "classifier"))) + } + + resourcesWithClassifier((r: Request[IO]) => + Some(s"tagged[num:${r.uri.query.params.getOrElse("num", "")}]") + ).test( + "Http client with a micrometer metrics middleware should use tags provided by the request classifier" + ) { case (registry, meteredClient) => + meteredClient + .statusFromString("/ok?num=one") + .assertEquals(Status.Ok) + .flatMap { _ => meteredClient.statusFromString("/ok?num=two").assertEquals(Status.Ok) } + .flatMap(_ => + IO(testMetersFor(registry, classifier = "tagged", additionalTags = Tags.of("num", "one"))) + ) + .flatMap(_ => + IO(testMetersFor(registry, classifier = "tagged", additionalTags = Tags.of("num", "two"))) + ) + } + + resources.test( + "Http client with a micrometer metrics middleware should only record total time and decr active requests after client.run releases" + ) { case (registry, meteredClient) => + meteredClient + .run(Request[IO](uri = Uri.unsafeFromString("/ok"))) + .use { resp => + IO(assertEquals(resp.status, Status.Ok)) + .flatMap { _ => + IO(assertEquals(meterValue(registry, Gauge("client.default.active-requests")), 1d)) + } + .flatMap { _ => + IO( + assertEquals( + meterMaxTime( + registry, + Timer("client.default.response-headers-time") + ), + 50.milliseconds + ) + ) + } + .flatMap { _ => + IO( + meterCount( + registry, + Timer(s"client.default.response-time") + ) + ).intercept[MeterNotFoundException] + } + } + + } +} diff --git a/http4s/src/test/scala/meters4s/http4s/ServerMetricsSuite.scala b/http4s/src/test/scala/meters4s/http4s/ServerMetricsSuite.scala new file mode 100644 index 0000000..d6ec366 --- /dev/null +++ b/http4s/src/test/scala/meters4s/http4s/ServerMetricsSuite.scala @@ -0,0 +1,434 @@ +package meters4s.http4s + +import scala.concurrent.duration._ + +import cats.effect._ + +import org.http4s._ +import org.http4s.dsl.io._ +import meters4s.http4s.util._ +import org.http4s.server.middleware.Metrics +import org.http4s.syntax.all._ +import meters4s.{MetricsConfig, Reporter} +import io.micrometer.core.instrument.search.MeterNotFoundException +import io.micrometer.core.instrument.{MeterRegistry, Tags} + +class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { + + def resourcesWithParams( + tags: Map[String, String] = Map.empty, + classifierF: Request[IO] => Option[String] = _ => None + ) = + ResourceFunFixture { + meterRegistryResource.evalMap { registry => + val config: MetricsConfig = MetricsConfig("server.", tags) + Reporter.fromRegistry[IO](registry, config).map { reporter => + implicit val clock: Clock[IO] = FakeClock[IO] + + val metrics = Meters4s[IO](reporter) + + val stubRoutes = HttpRoutes.of[IO](stub) + val meteredStubRoutes = Metrics[IO](metrics, classifierF = classifierF)(stubRoutes) + + (registry, meteredStubRoutes) + } + } + } + + val resources = resourcesWithParams() + + // val stubRoutes = HttpRoutes.of[IO](stub) + + def testMetersFor( + registry: MeterRegistry, + method: String = "get", + statusCode: String = "2xx", + classifier: String = "default", + termination: String = "normal", + additionalTags: Tags = Tags.empty + ) = { + + // TODO test for non existence of classifier + + val allStatuses = List( + "2xx", + "3xx", + "4xx", + "5xx" + ) + + val allMethods = List( + "get", + "put", + "post", + "patch", + "delete", + "head", + "move", + "options", + "trace", + "connect", + "other" + ) + + val allTerminations = List( + "abnormal", + "error", + "timeout" + ) + + allStatuses.filter(_ != statusCode).foreach { x => + intercept[MeterNotFoundException] { + meterCount( + registry, + Timer(s"server.${classifier}.response-time", additionalTags and Tags.of("status-code", x)) + ) + } + } + + allMethods.filter(_ != method).foreach { x => + intercept[MeterNotFoundException] { + meterCount( + registry, + Timer(s"server.${classifier}.response-time", additionalTags and Tags.of("method", x)) + ) + } + + intercept[MeterNotFoundException] { + meterCount( + registry, + Timer( + s"server.${classifier}.response-headers-time", + additionalTags and Tags.of("method", x) + ) + ) + } + } + + allTerminations.filter(_ != termination).foreach { x => + intercept[MeterNotFoundException] { + meterCount( + registry, + Timer(s"server.${classifier}.response-time", additionalTags and Tags.of("termination", x)) + ) + } + } + + val responseTimeTags = if (termination != "normal") { + Tags.of("termination", termination) + } else { + Tags.of("status-code", statusCode, "method", method, "termination", termination) + } + + assertEquals( + meterCount( + registry, + Timer( + s"server.${classifier}.response-time", + additionalTags and responseTimeTags + ) + ), + 1L + ) + + if (termination == "normal") { + assertEquals( + meterMaxTime( + registry, + Timer( + s"server.${classifier}.response-time", + additionalTags and responseTimeTags + ) + ), + 100.milliseconds + ) + + assertEquals( + meterTotalTime( + registry, + Timer( + s"server.${classifier}.response-time", + additionalTags and responseTimeTags + ) + ), + 100.milliseconds + ) + } + + assertEquals( + meterCount( + registry, + Timer( + s"server.${classifier}.response-headers-time", + additionalTags and Tags.of("method", method) + ) + ), + 1L + ) + + assertEquals( + meterMaxTime( + registry, + Timer( + s"server.${classifier}.response-headers-time", + additionalTags and Tags.of("method", method) + ) + ), + 50.milliseconds + ) + + assertEquals( + meterTotalTime( + registry, + Timer( + s"server.${classifier}.response-headers-time", + additionalTags and Tags.of("method", method) + ) + ), + 50.milliseconds + ) + + assertEquals( + meterValue( + registry, + Gauge(s"server.${classifier}.active-requests", additionalTags) + ), + 0d + ) + } + + resources.test( + "Http routes with a micrometer metrics middleware should register a 2xx response" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "get", "2xx"))) + } + + resources.test( + "Http routes with a micrometer metrics middleware should register a 4xx response" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/bad-request")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.BadRequest) + .flatMap(_ => IO(testMetersFor(registry, "get", "4xx"))) + } + + resources.test( + "Http routes with a micrometer metrics middleware should register a 5xx response" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/internal-server-error")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.InternalServerError) + .flatMap(_ => IO(testMetersFor(registry, "get", "5xx"))) + + } + + resources.test( + "Http routes with a micrometer metrics middleware should register a POST request" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = POST, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "post", "2xx"))) + } + + resources.test( + "Http routes with a micrometer metrics middleware should register a PUT request" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = PUT, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "put", "2xx"))) + } + + resources.test( + "Http routes with a micrometer metrics middleware should register a PATCH request" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = PATCH, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "patch", "2xx"))) + } + + resources.test( + "Http routes with a micrometer metrics middleware should register a DELETE request" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = DELETE, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "delete", "2xx"))) + } + + resources.test( + "Http routes with a micrometer metrics middleware should register a HEAD request" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = HEAD, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "head", "2xx"))) + } + + resources.test( + "Http routes with a micrometer metrics middleware should register a OPTIONS request" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = OPTIONS, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "options", "2xx"))) + } + + resources.test( + "Http routes with a micrometer metrics middleware should register an error" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/error")) + .intercept[Throwable] + .flatMap(_ => IO(testMetersFor(registry, statusCode = "5xx", termination = "error"))) + } + + resources.test( + "Http routes with a micrometer metrics middleware should register an abnormal termination" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/abnormal-termination")) + .flatMap(resp => resp.body.attempt.compile.lastOrError) + .flatMap(_ => IO(testMetersFor(registry, termination = "abnormal"))) + } + + // // TODO how to simulate a timeout??? + + resourcesWithParams(classifierF = (_: Request[IO]) => Some("classifier")).test( + "Http routes with a micrometer metrics middleware should use the provided request classifier" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, classifier = "classifier"))) + } + + resourcesWithParams(tags = Map("foo" -> "bar")).test( + "Http routes with a micrometer metrics middleware should tags metrics using global tags" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, additionalTags = Tags.of("foo", "bar")))) + } + + resourcesWithParams( + tags = Map("foo" -> "bar", "bar" -> "baz"), + classifierF = (_: Request[IO]) => Some("classifier[bar:bazv2,baz:bar]") + ).test( + "Http routes with a micrometer metrics middleware should use the provided request classifier to overwrite the tags" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO( + testMetersFor( + registry, + classifier = "classifier", + additionalTags = Tags.of("foo", "bar", "bar", "bazv2", "baz", "bar") + ) + ) + ) + } + + resourcesWithParams( + tags = Map("foo" -> "bar", "bar" -> "baz"), + classifierF = (_: Request[IO]) => Some("[bar:bazv2,baz:bar]") + ).test( + "Http routes with a micrometer metrics middleware should use the provided request empty classifier to overwrite the tags" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO( + testMetersFor( + registry, + additionalTags = Tags.of("foo", "bar", "bar", "bazv2", "baz", "bar") + ) + ) + ) + } + + resourcesWithParams( + tags = Map("foo" -> "bar", "bar" -> "baz"), + classifierF = (_: Request[IO]) => Some("classifier[]") + ).test( + "Http routes with a micrometer metrics middleware should handle classifier with empty tags" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO( + testMetersFor( + registry, + classifier = "classifier", + additionalTags = Tags.of("foo", "bar", "bar", "baz") + ) + ) + ) + } + + resourcesWithParams( + tags = Map("foo" -> "bar", "bar" -> "baz"), + classifierF = (_: Request[IO]) => Some("classifier") + ).test( + "Http routes with a micrometer metrics middleware should handle classifier with no tags" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO( + testMetersFor( + registry, + classifier = "classifier", + additionalTags = Tags.of("foo", "bar", "bar", "baz") + ) + ) + ) + } + + resourcesWithParams( + classifierF = (_: Request[IO]) => Some("classifier[ ]") + ).test( + "Http routes with a micrometer metrics middleware should handle blank tags" + ) { case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO( + testMetersFor( + registry, + classifier = "classifier", + additionalTags = Tags.empty + ) + ) + ) + } + +} diff --git a/http4s/src/test/scala/meters4s/http4s/util.scala b/http4s/src/test/scala/meters4s/http4s/util.scala new file mode 100644 index 0000000..0095fca --- /dev/null +++ b/http4s/src/test/scala/meters4s/http4s/util.scala @@ -0,0 +1,104 @@ +package meters4s.http4s + +import java.io.IOException +import java.util.concurrent.{TimeUnit, TimeoutException} +import scala.concurrent.duration._ + +import cats.Applicative +import cats.effect._ +import fs2.Stream + +import org.http4s.Method.GET +import org.http4s.dsl.io._ +import org.http4s.{Request, Response} +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.micrometer.core.instrument.{MeterRegistry, Tags} + +object util { + + def stub: PartialFunction[Request[IO], IO[Response[IO]]] = { + case (HEAD | GET | POST | PUT | PATCH | DELETE | OPTIONS | TRACE | CONNECT) -> Root / "ok" => + Ok("200 OK") + case _ -> Root / "bad-request" => + BadRequest("400 Bad Request") + case _ -> Root / "internal-server-error" => + InternalServerError("500 Internal Server Error") + case _ -> Root / "error" => + IO.raiseError[Response[IO]](new IOException("error")) + case _ -> Root / "timeout" => + IO.raiseError[Response[IO]](new TimeoutException("request timed out")) + case _ -> Root / "abnormal-termination" => + Ok("200 OK").map( + _.withBodyStream(Stream.raiseError[IO](new RuntimeException("Abnormal termination"))) + ) + case _ => + NotFound("404 Not Found") + } + + def meterValue(registry: MeterRegistry, meter: Gauge): Double = + registry.get(meter.name).tags(meter.tags).gauge().value + + def meterCount(registry: MeterRegistry, meter: Counter): Double = + registry.get(meter.name).tags(meter.tags).counter().count + + def meterCount(registry: MeterRegistry, meter: Timer): Long = + registry.get(meter.name).tags(meter.tags).timer().count + + def meterTotalTime(registry: MeterRegistry, meter: Timer): FiniteDuration = + FiniteDuration( + registry + .get(meter.name) + .tags(meter.tags) + .timer() + .totalTime(TimeUnit.NANOSECONDS) + .toLong, + TimeUnit.NANOSECONDS + ) + + def meterMeanTime(registry: MeterRegistry, meter: Timer): FiniteDuration = + FiniteDuration( + registry + .get(meter.name) + .tags(meter.tags) + .timer() + .mean(TimeUnit.NANOSECONDS) + .toLong, + TimeUnit.NANOSECONDS + ) + + def meterMaxTime(registry: MeterRegistry, meter: Timer): FiniteDuration = + FiniteDuration( + registry + .get(meter.name) + .tags(meter.tags) + .timer() + .max(TimeUnit.NANOSECONDS) + .toLong, + TimeUnit.NANOSECONDS + ) + + case class Gauge(name: String, tags: Tags = Tags.empty) + case class Counter(name: String, tags: Tags = Tags.empty) + case class Timer(name: String, tags: Tags = Tags.empty) + + object FakeClock { + def apply[F[_]: Sync] = new Clock[F] { + private var count = 0L + + def applicative: Applicative[F] = Sync[F] + + override def realTime: F[FiniteDuration] = Sync[F].delay { + count += 50 + count.milliseconds + } + + override def monotonic: F[FiniteDuration] = Sync[F].delay { + count += 50 + count.milliseconds + } + } + } + + val meterRegistryResource: Resource[IO, SimpleMeterRegistry] = + Resource.make(IO(new SimpleMeterRegistry))(r => IO(r.close)) +} From 442147cf3b8abc2ea8e4c8bd4a4bd67c99340da3 Mon Sep 17 00:00:00 2001 From: Keir Lawson Date: Tue, 31 Oct 2023 10:28:52 +0000 Subject: [PATCH 3/5] Move dirs to match packages --- core/src/main/scala/{com/ovoenergy => }/meters4s/Reporter.scala | 0 core/src/main/scala/{com/ovoenergy => }/meters4s/syntax.scala | 0 .../test/scala/{com/ovoenergy => }/meters4s/ReporterTest.scala | 0 .../main/scala/{com/ovoenergy => }/meters4s/datadog/DataDog.scala | 0 .../scala/{com/ovoenergy => }/meter4s/prometheus/Prometheus.scala | 0 .../main/scala/{com/ovoenergy => }/meters4s/statsd/StatsD.scala | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename core/src/main/scala/{com/ovoenergy => }/meters4s/Reporter.scala (100%) rename core/src/main/scala/{com/ovoenergy => }/meters4s/syntax.scala (100%) rename core/src/test/scala/{com/ovoenergy => }/meters4s/ReporterTest.scala (100%) rename datadog/src/main/scala/{com/ovoenergy => }/meters4s/datadog/DataDog.scala (100%) rename prometheus/src/main/scala/{com/ovoenergy => }/meter4s/prometheus/Prometheus.scala (100%) rename statsd/src/main/scala/{com/ovoenergy => }/meters4s/statsd/StatsD.scala (100%) diff --git a/core/src/main/scala/com/ovoenergy/meters4s/Reporter.scala b/core/src/main/scala/meters4s/Reporter.scala similarity index 100% rename from core/src/main/scala/com/ovoenergy/meters4s/Reporter.scala rename to core/src/main/scala/meters4s/Reporter.scala diff --git a/core/src/main/scala/com/ovoenergy/meters4s/syntax.scala b/core/src/main/scala/meters4s/syntax.scala similarity index 100% rename from core/src/main/scala/com/ovoenergy/meters4s/syntax.scala rename to core/src/main/scala/meters4s/syntax.scala diff --git a/core/src/test/scala/com/ovoenergy/meters4s/ReporterTest.scala b/core/src/test/scala/meters4s/ReporterTest.scala similarity index 100% rename from core/src/test/scala/com/ovoenergy/meters4s/ReporterTest.scala rename to core/src/test/scala/meters4s/ReporterTest.scala diff --git a/datadog/src/main/scala/com/ovoenergy/meters4s/datadog/DataDog.scala b/datadog/src/main/scala/meters4s/datadog/DataDog.scala similarity index 100% rename from datadog/src/main/scala/com/ovoenergy/meters4s/datadog/DataDog.scala rename to datadog/src/main/scala/meters4s/datadog/DataDog.scala diff --git a/prometheus/src/main/scala/com/ovoenergy/meter4s/prometheus/Prometheus.scala b/prometheus/src/main/scala/meter4s/prometheus/Prometheus.scala similarity index 100% rename from prometheus/src/main/scala/com/ovoenergy/meter4s/prometheus/Prometheus.scala rename to prometheus/src/main/scala/meter4s/prometheus/Prometheus.scala diff --git a/statsd/src/main/scala/com/ovoenergy/meters4s/statsd/StatsD.scala b/statsd/src/main/scala/meters4s/statsd/StatsD.scala similarity index 100% rename from statsd/src/main/scala/com/ovoenergy/meters4s/statsd/StatsD.scala rename to statsd/src/main/scala/meters4s/statsd/StatsD.scala From 3840689008aadb57eaf41c503cc260b32700bd08 Mon Sep 17 00:00:00 2001 From: Keir Lawson Date: Tue, 31 Oct 2023 10:29:59 +0000 Subject: [PATCH 4/5] format --- http4s/src/main/scala/Meters4s.scala | 27 +- .../meters4s/http4s/ClientMetricsSuite.scala | 245 ++++++++----- .../meters4s/http4s/ServerMetricsSuite.scala | 328 ++++++++++-------- .../src/test/scala/meters4s/http4s/util.scala | 7 +- 4 files changed, 356 insertions(+), 251 deletions(-) diff --git a/http4s/src/main/scala/Meters4s.scala b/http4s/src/main/scala/Meters4s.scala index a4ec625..9d5b8e8 100644 --- a/http4s/src/main/scala/Meters4s.scala +++ b/http4s/src/main/scala/Meters4s.scala @@ -37,8 +37,9 @@ object Meters4s { case TagsReg(tagsString) if tagsString.trim.nonEmpty => tagsString .split(",") - .collect { case TagReg(key, value) => - Map(key -> value) + .collect { + case TagReg(key, value) => + Map(key -> value) } .reduce(_ ++ _) } @@ -47,10 +48,14 @@ object Meters4s { } def increaseActiveRequests(classifier: Option[String]): F[Unit] = - reporter.gauge(name(classifier, "active-requests"), tags(classifier)).flatMap(_.increment) + reporter + .gauge(name(classifier, "active-requests"), tags(classifier)) + .flatMap(_.increment) def decreaseActiveRequests(classifier: Option[String]): F[Unit] = - reporter.gauge(name(classifier, "active-requests"), tags(classifier)).flatMap(_.decrement) + reporter + .gauge(name(classifier, "active-requests"), tags(classifier)) + .flatMap(_.decrement) def recordHeadersTime( method: Method, @@ -72,9 +77,9 @@ object Meters4s { ): F[Unit] = { val terminationTags = terminationType match { case Abnormal(_) => "termination" -> "abnormal" - case Error(_) => "termination" -> "error" - case Canceled => "termination" -> "cancelled" - case Timeout => "termination" -> "timeout" + case Error(_) => "termination" -> "error" + case Canceled => "termination" -> "cancelled" + case Timeout => "termination" -> "timeout" } recordResponseTime( @@ -91,10 +96,10 @@ object Meters4s { ): F[Unit] = { val statusTags = status.responseClass match { case Status.Informational => "status-code" -> "1xx" - case Status.Successful => "status-code" -> "2xx" - case Status.Redirection => "status-code" -> "3xx" - case Status.ClientError => "status-code" -> "4xx" - case Status.ServerError => "status-code" -> "5xx" + case Status.Successful => "status-code" -> "2xx" + case Status.Redirection => "status-code" -> "3xx" + case Status.ClientError => "status-code" -> "4xx" + case Status.ServerError => "status-code" -> "5xx" } val allTags = tags(classifier) ++ Map("termination" -> "normal", statusTags) ++ diff --git a/http4s/src/test/scala/meters4s/http4s/ClientMetricsSuite.scala b/http4s/src/test/scala/meters4s/http4s/ClientMetricsSuite.scala index 5126bcd..956f1cc 100644 --- a/http4s/src/test/scala/meters4s/http4s/ClientMetricsSuite.scala +++ b/http4s/src/test/scala/meters4s/http4s/ClientMetricsSuite.scala @@ -18,7 +18,9 @@ import io.micrometer.core.instrument.{MeterRegistry, Tags} class ClientMetricsSuite extends munit.CatsEffectSuite { - def resourcesWithClassifier(classifierF: Request[IO] => Option[String] = _ => None) = + def resourcesWithClassifier( + classifierF: Request[IO] => Option[String] = _ => None + ) = ResourceFunFixture { meterRegistryResource.evalMap { registry => val config: MetricsConfig = MetricsConfig("client.") @@ -78,7 +80,10 @@ class ClientMetricsSuite extends munit.CatsEffectSuite { intercept[MeterNotFoundException] { meterCount( registry, - Timer(s"client.${classifier}.response-time", additionalTags and Tags.of("status-code", x)) + Timer( + s"client.${classifier}.response-time", + additionalTags and Tags.of("status-code", x) + ) ) } } @@ -87,7 +92,10 @@ class ClientMetricsSuite extends munit.CatsEffectSuite { intercept[MeterNotFoundException] { meterCount( registry, - Timer(s"client.${classifier}.response-time", additionalTags and Tags.of("method", x)) + Timer( + s"client.${classifier}.response-time", + additionalTags and Tags.of("method", x) + ) ) } @@ -106,7 +114,10 @@ class ClientMetricsSuite extends munit.CatsEffectSuite { intercept[MeterNotFoundException] { meterCount( registry, - Timer(s"client.${classifier}.response-time", additionalTags and Tags.of("termination", x)) + Timer( + s"client.${classifier}.response-time", + additionalTags and Tags.of("termination", x) + ) ) } } @@ -114,7 +125,14 @@ class ClientMetricsSuite extends munit.CatsEffectSuite { val responseTimeTags = if (termination != "normal") { Tags.of("termination", termination) } else { - Tags.of("status-code", statusCode, "method", method, "termination", termination) + Tags.of( + "status-code", + statusCode, + "method", + method, + "termination", + termination + ) } assertEquals( @@ -196,51 +214,58 @@ class ClientMetricsSuite extends munit.CatsEffectSuite { resources.test( "Http client with a micrometer metrics middleware should register a 2xx response" - ) { case (registry, meteredClient) => - meteredClient - .statusFromString("/ok") - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry))) + ) { + case (registry, meteredClient) => + meteredClient + .statusFromString("/ok") + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry))) } resources.test( "Http client with a micrometer metrics middleware should register a 4xx response" - ) { case (registry, meteredClient) => - meteredClient - .statusFromString("/bad-request") - .assertEquals(Status.BadRequest) - .flatMap(_ => IO(testMetersFor(registry, statusCode = "4xx"))) + ) { + case (registry, meteredClient) => + meteredClient + .statusFromString("/bad-request") + .assertEquals(Status.BadRequest) + .flatMap(_ => IO(testMetersFor(registry, statusCode = "4xx"))) } resources.test( "Http client with a micrometer metrics middleware should register a 5xx response" - ) { case (registry, meteredClient) => - meteredClient - .statusFromString("/internal-server-error") - .assertEquals(Status.InternalServerError) - .flatMap(_ => IO(testMetersFor(registry, statusCode = "5xx"))) + ) { + case (registry, meteredClient) => + meteredClient + .statusFromString("/internal-server-error") + .assertEquals(Status.InternalServerError) + .flatMap(_ => IO(testMetersFor(registry, statusCode = "5xx"))) } resources.test( "Http client with a micrometer metrics middleware should register a GET request" - ) { case (registry, meteredClient) => - meteredClient - .statusFromString("/ok") - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, method = "get"))) + ) { + case (registry, meteredClient) => + meteredClient + .statusFromString("/ok") + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "get"))) } resources.test( "Http client with a micrometer metrics middleware should register a POST request" - ) { case (registry, meteredClient) => - meteredClient - .status(Request[IO](POST, uri"/ok")) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, method = "post"))) + ) { + case (registry, meteredClient) => + meteredClient + .status(Request[IO](POST, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "post"))) } - resources.test("Http client with a micrometer metrics middleware should register a PUT request") { + resources.test( + "Http client with a micrometer metrics middleware should register a PUT request" + ) { case (registry, meteredClient) => meteredClient .status(Request[IO](PUT, uri"/ok")) @@ -251,41 +276,47 @@ class ClientMetricsSuite extends munit.CatsEffectSuite { resources.test( "Http client with a micrometer metrics middleware should register a PATCH request" - ) { case (registry, meteredClient) => - meteredClient - .status(Request[IO](PATCH, uri"/ok")) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, method = "patch"))) + ) { + case (registry, meteredClient) => + meteredClient + .status(Request[IO](PATCH, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "patch"))) } resources.test( "Http client with a micrometer metrics middleware should register a DELETE request" - ) { case (registry, meteredClient) => - meteredClient - .status(Request[IO](DELETE, uri"/ok")) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, method = "delete"))) + ) { + case (registry, meteredClient) => + meteredClient + .status(Request[IO](DELETE, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "delete"))) } resources.test( "Http client with a micrometer metrics middleware should register a HEAD request" - ) { case (registry, meteredClient) => - meteredClient - .status(Request[IO](HEAD, uri"/ok")) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, method = "head"))) + ) { + case (registry, meteredClient) => + meteredClient + .status(Request[IO](HEAD, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "head"))) } resources.test( "Http client with a micrometer metrics middleware should register a OPTIONS request" - ) { case (registry, meteredClient) => - meteredClient - .status(Request[IO](OPTIONS, uri"/ok")) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, method = "options"))) + ) { + case (registry, meteredClient) => + meteredClient + .status(Request[IO](OPTIONS, uri"/ok")) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, method = "options"))) } - resources.test("Http client with a micrometer metrics middleware should register an error") { + resources.test( + "Http client with a micrometer metrics middleware should register an error" + ) { case (registry, meteredClient) => meteredClient .statusFromString("/error") @@ -293,7 +324,9 @@ class ClientMetricsSuite extends munit.CatsEffectSuite { .flatMap(_ => IO(testMetersFor(registry, termination = "error"))) } - resources.test("Http client with a micrometer metrics middleware should register a timeout") { + resources.test( + "Http client with a micrometer metrics middleware should register a timeout" + ) { case (registry, meteredClient) => meteredClient .statusFromString("/timeout") @@ -303,60 +336,82 @@ class ClientMetricsSuite extends munit.CatsEffectSuite { resourcesWithClassifier((_: Request[IO]) => Some("classifier")).test( "Http client with a micrometer metrics middleware should use the provided request classifier" - ) { case (registry, meteredClient) => - meteredClient - .statusFromString("/ok") - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, classifier = "classifier"))) + ) { + case (registry, meteredClient) => + meteredClient + .statusFromString("/ok") + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, classifier = "classifier"))) } resourcesWithClassifier((r: Request[IO]) => Some(s"tagged[num:${r.uri.query.params.getOrElse("num", "")}]") ).test( "Http client with a micrometer metrics middleware should use tags provided by the request classifier" - ) { case (registry, meteredClient) => - meteredClient - .statusFromString("/ok?num=one") - .assertEquals(Status.Ok) - .flatMap { _ => meteredClient.statusFromString("/ok?num=two").assertEquals(Status.Ok) } - .flatMap(_ => - IO(testMetersFor(registry, classifier = "tagged", additionalTags = Tags.of("num", "one"))) - ) - .flatMap(_ => - IO(testMetersFor(registry, classifier = "tagged", additionalTags = Tags.of("num", "two"))) - ) + ) { + case (registry, meteredClient) => + meteredClient + .statusFromString("/ok?num=one") + .assertEquals(Status.Ok) + .flatMap { _ => + meteredClient.statusFromString("/ok?num=two").assertEquals(Status.Ok) + } + .flatMap(_ => + IO( + testMetersFor( + registry, + classifier = "tagged", + additionalTags = Tags.of("num", "one") + ) + ) + ) + .flatMap(_ => + IO( + testMetersFor( + registry, + classifier = "tagged", + additionalTags = Tags.of("num", "two") + ) + ) + ) } resources.test( "Http client with a micrometer metrics middleware should only record total time and decr active requests after client.run releases" - ) { case (registry, meteredClient) => - meteredClient - .run(Request[IO](uri = Uri.unsafeFromString("/ok"))) - .use { resp => - IO(assertEquals(resp.status, Status.Ok)) - .flatMap { _ => - IO(assertEquals(meterValue(registry, Gauge("client.default.active-requests")), 1d)) - } - .flatMap { _ => - IO( - assertEquals( - meterMaxTime( - registry, - Timer("client.default.response-headers-time") - ), - 50.milliseconds + ) { + case (registry, meteredClient) => + meteredClient + .run(Request[IO](uri = Uri.unsafeFromString("/ok"))) + .use { resp => + IO(assertEquals(resp.status, Status.Ok)) + .flatMap { _ => + IO( + assertEquals( + meterValue(registry, Gauge("client.default.active-requests")), + 1d + ) ) - ) - } - .flatMap { _ => - IO( - meterCount( - registry, - Timer(s"client.default.response-time") + } + .flatMap { _ => + IO( + assertEquals( + meterMaxTime( + registry, + Timer("client.default.response-headers-time") + ), + 50.milliseconds + ) ) - ).intercept[MeterNotFoundException] - } - } + } + .flatMap { _ => + IO( + meterCount( + registry, + Timer(s"client.default.response-time") + ) + ).intercept[MeterNotFoundException] + } + } } } diff --git a/http4s/src/test/scala/meters4s/http4s/ServerMetricsSuite.scala b/http4s/src/test/scala/meters4s/http4s/ServerMetricsSuite.scala index d6ec366..061e13e 100644 --- a/http4s/src/test/scala/meters4s/http4s/ServerMetricsSuite.scala +++ b/http4s/src/test/scala/meters4s/http4s/ServerMetricsSuite.scala @@ -28,7 +28,8 @@ class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { val metrics = Meters4s[IO](reporter) val stubRoutes = HttpRoutes.of[IO](stub) - val meteredStubRoutes = Metrics[IO](metrics, classifierF = classifierF)(stubRoutes) + val meteredStubRoutes = + Metrics[IO](metrics, classifierF = classifierF)(stubRoutes) (registry, meteredStubRoutes) } @@ -81,7 +82,10 @@ class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { intercept[MeterNotFoundException] { meterCount( registry, - Timer(s"server.${classifier}.response-time", additionalTags and Tags.of("status-code", x)) + Timer( + s"server.${classifier}.response-time", + additionalTags and Tags.of("status-code", x) + ) ) } } @@ -90,7 +94,10 @@ class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { intercept[MeterNotFoundException] { meterCount( registry, - Timer(s"server.${classifier}.response-time", additionalTags and Tags.of("method", x)) + Timer( + s"server.${classifier}.response-time", + additionalTags and Tags.of("method", x) + ) ) } @@ -109,7 +116,10 @@ class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { intercept[MeterNotFoundException] { meterCount( registry, - Timer(s"server.${classifier}.response-time", additionalTags and Tags.of("termination", x)) + Timer( + s"server.${classifier}.response-time", + additionalTags and Tags.of("termination", x) + ) ) } } @@ -117,7 +127,14 @@ class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { val responseTimeTags = if (termination != "normal") { Tags.of("termination", termination) } else { - Tags.of("status-code", statusCode, "method", method, "termination", termination) + Tags.of( + "status-code", + statusCode, + "method", + method, + "termination", + termination + ) } assertEquals( @@ -199,133 +216,151 @@ class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { resources.test( "Http routes with a micrometer metrics middleware should register a 2xx response" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, "get", "2xx"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "get", "2xx"))) } resources.test( "Http routes with a micrometer metrics middleware should register a 4xx response" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/bad-request")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.BadRequest) - .flatMap(_ => IO(testMetersFor(registry, "get", "4xx"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/bad-request")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.BadRequest) + .flatMap(_ => IO(testMetersFor(registry, "get", "4xx"))) } resources.test( "Http routes with a micrometer metrics middleware should register a 5xx response" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/internal-server-error")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.InternalServerError) - .flatMap(_ => IO(testMetersFor(registry, "get", "5xx"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/internal-server-error")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.InternalServerError) + .flatMap(_ => IO(testMetersFor(registry, "get", "5xx"))) } resources.test( "Http routes with a micrometer metrics middleware should register a POST request" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](method = POST, uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, "post", "2xx"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = POST, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "post", "2xx"))) } resources.test( "Http routes with a micrometer metrics middleware should register a PUT request" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](method = PUT, uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, "put", "2xx"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = PUT, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "put", "2xx"))) } resources.test( "Http routes with a micrometer metrics middleware should register a PATCH request" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](method = PATCH, uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, "patch", "2xx"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = PATCH, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "patch", "2xx"))) } resources.test( "Http routes with a micrometer metrics middleware should register a DELETE request" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](method = DELETE, uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, "delete", "2xx"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = DELETE, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "delete", "2xx"))) } resources.test( "Http routes with a micrometer metrics middleware should register a HEAD request" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](method = HEAD, uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, "head", "2xx"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = HEAD, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "head", "2xx"))) } resources.test( "Http routes with a micrometer metrics middleware should register a OPTIONS request" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](method = OPTIONS, uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, "options", "2xx"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](method = OPTIONS, uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, "options", "2xx"))) } resources.test( "Http routes with a micrometer metrics middleware should register an error" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/error")) - .intercept[Throwable] - .flatMap(_ => IO(testMetersFor(registry, statusCode = "5xx", termination = "error"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/error")) + .intercept[Throwable] + .flatMap(_ => + IO(testMetersFor(registry, statusCode = "5xx", termination = "error")) + ) } resources.test( "Http routes with a micrometer metrics middleware should register an abnormal termination" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/abnormal-termination")) - .flatMap(resp => resp.body.attempt.compile.lastOrError) - .flatMap(_ => IO(testMetersFor(registry, termination = "abnormal"))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/abnormal-termination")) + .flatMap(resp => resp.body.attempt.compile.lastOrError) + .flatMap(_ => IO(testMetersFor(registry, termination = "abnormal"))) } // // TODO how to simulate a timeout??? - resourcesWithParams(classifierF = (_: Request[IO]) => Some("classifier")).test( - "Http routes with a micrometer metrics middleware should use the provided request classifier" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, classifier = "classifier"))) - } + resourcesWithParams(classifierF = (_: Request[IO]) => Some("classifier")) + .test( + "Http routes with a micrometer metrics middleware should use the provided request classifier" + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => IO(testMetersFor(registry, classifier = "classifier"))) + } resourcesWithParams(tags = Map("foo" -> "bar")).test( "Http routes with a micrometer metrics middleware should tags metrics using global tags" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => IO(testMetersFor(registry, additionalTags = Tags.of("foo", "bar")))) + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO(testMetersFor(registry, additionalTags = Tags.of("foo", "bar"))) + ) } resourcesWithParams( @@ -333,20 +368,22 @@ class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { classifierF = (_: Request[IO]) => Some("classifier[bar:bazv2,baz:bar]") ).test( "Http routes with a micrometer metrics middleware should use the provided request classifier to overwrite the tags" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => - IO( - testMetersFor( - registry, - classifier = "classifier", - additionalTags = Tags.of("foo", "bar", "bar", "bazv2", "baz", "bar") + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO( + testMetersFor( + registry, + classifier = "classifier", + additionalTags = + Tags.of("foo", "bar", "bar", "bazv2", "baz", "bar") + ) ) ) - ) } resourcesWithParams( @@ -354,19 +391,21 @@ class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { classifierF = (_: Request[IO]) => Some("[bar:bazv2,baz:bar]") ).test( "Http routes with a micrometer metrics middleware should use the provided request empty classifier to overwrite the tags" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => - IO( - testMetersFor( - registry, - additionalTags = Tags.of("foo", "bar", "bar", "bazv2", "baz", "bar") + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO( + testMetersFor( + registry, + additionalTags = + Tags.of("foo", "bar", "bar", "bazv2", "baz", "bar") + ) ) ) - ) } resourcesWithParams( @@ -374,20 +413,21 @@ class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { classifierF = (_: Request[IO]) => Some("classifier[]") ).test( "Http routes with a micrometer metrics middleware should handle classifier with empty tags" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => - IO( - testMetersFor( - registry, - classifier = "classifier", - additionalTags = Tags.of("foo", "bar", "bar", "baz") + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO( + testMetersFor( + registry, + classifier = "classifier", + additionalTags = Tags.of("foo", "bar", "bar", "baz") + ) ) ) - ) } resourcesWithParams( @@ -395,40 +435,42 @@ class MicrometerServerMetricsSuite extends munit.CatsEffectSuite { classifierF = (_: Request[IO]) => Some("classifier") ).test( "Http routes with a micrometer metrics middleware should handle classifier with no tags" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => - IO( - testMetersFor( - registry, - classifier = "classifier", - additionalTags = Tags.of("foo", "bar", "bar", "baz") + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO( + testMetersFor( + registry, + classifier = "classifier", + additionalTags = Tags.of("foo", "bar", "bar", "baz") + ) ) ) - ) } resourcesWithParams( classifierF = (_: Request[IO]) => Some("classifier[ ]") ).test( "Http routes with a micrometer metrics middleware should handle blank tags" - ) { case (registry, meteredRoutes) => - meteredRoutes - .orNotFound(Request[IO](uri = uri"/ok")) - .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) - .assertEquals(Status.Ok) - .flatMap(_ => - IO( - testMetersFor( - registry, - classifier = "classifier", - additionalTags = Tags.empty + ) { + case (registry, meteredRoutes) => + meteredRoutes + .orNotFound(Request[IO](uri = uri"/ok")) + .flatMap(r => r.bodyText.compile.lastOrError.as(r.status)) + .assertEquals(Status.Ok) + .flatMap(_ => + IO( + testMetersFor( + registry, + classifier = "classifier", + additionalTags = Tags.empty + ) ) ) - ) } } diff --git a/http4s/src/test/scala/meters4s/http4s/util.scala b/http4s/src/test/scala/meters4s/http4s/util.scala index 0095fca..8c793d7 100644 --- a/http4s/src/test/scala/meters4s/http4s/util.scala +++ b/http4s/src/test/scala/meters4s/http4s/util.scala @@ -17,7 +17,8 @@ import io.micrometer.core.instrument.{MeterRegistry, Tags} object util { def stub: PartialFunction[Request[IO], IO[Response[IO]]] = { - case (HEAD | GET | POST | PUT | PATCH | DELETE | OPTIONS | TRACE | CONNECT) -> Root / "ok" => + case (HEAD | GET | POST | PUT | PATCH | DELETE | OPTIONS | TRACE | + CONNECT) -> Root / "ok" => Ok("200 OK") case _ -> Root / "bad-request" => BadRequest("400 Bad Request") @@ -29,7 +30,9 @@ object util { IO.raiseError[Response[IO]](new TimeoutException("request timed out")) case _ -> Root / "abnormal-termination" => Ok("200 OK").map( - _.withBodyStream(Stream.raiseError[IO](new RuntimeException("Abnormal termination"))) + _.withBodyStream( + Stream.raiseError[IO](new RuntimeException("Abnormal termination")) + ) ) case _ => NotFound("404 Not Found") From 1ef28ca2784415197cbd735f3ce1c93b1e897e52 Mon Sep 17 00:00:00 2001 From: Keir Lawson Date: Tue, 31 Oct 2023 11:13:11 +0000 Subject: [PATCH 5/5] Update readme --- README.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ae70924..7956d17 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,55 @@ object PromExampleApp extends IOApp { } ``` +## HTTP4S + +`meters4s-http4s` implements [http4s](https://http4s.org/) metrics. + +This module records the following meters: + +- Timer `default.response-time` +- Timer `default.response-headers-time` +- Gauge `default.active-requests` + +The `default.response-time` timer has the `status-code`, `method` and `termination` tags. +The `default.response-headers-time` timer has the `method` tag. +The `default.active-requests` does not have any tag. + +In addition to these tags, each metric will record the global tags set in the Config. + +It is also possible to set a prefix for the metrics name using the `prefix` configuration setting. + +The `default` name can be customised using a classifier function. With the same classifier function, it is possible to record additional tags using this syntax: `classifier[tag1:value1,tag2:value2,tag3:value3]`. The classifier part can be blank as well as the tags part can be empty. + +The standard tags values are the following: + +- statusCode + - 2xx + - 3xx + - 4xx + - 5xx + +- method + - head + - get + - put + - patch + - post + - delete + - options + - move + - trace + - connect + - other + +- termination + - normal + - abnormals + - error + - timeout + + ## Inspiration This library was heavily inspired by (and in some places copied wholesale -from) [http4s-micrometer-metrics](https://github.com/ovotech/http4s-micrometer-metrics). +from) [http4s-micrometer-metrics](https://github.com/ovotech/http4s-micrometer-metrics). \ No newline at end of file