diff --git a/build.sbt b/build.sbt index b583d77..7cd358c 100644 --- a/build.sbt +++ b/build.sbt @@ -55,8 +55,11 @@ lazy val `core-jvm-tests` = project .settings( libraryDependencies ++= Seq( "io.opentelemetry" % "opentelemetry-sdk-testing" % openTelemetryV % Test, + "org.http4s" %%% "http4s-server" % http4sV % Test, "org.typelevel" %%% "cats-effect-testkit" % catsEffectV % Test, "org.typelevel" %%% "munit-cats-effect" % munitCatsEffectV % Test, + "org.typelevel" %%% "otel4s-oteljava-metrics" % otel4sV % Test, + "org.typelevel" %%% "otel4s-oteljava-metrics-testkit" % otel4sV % Test, "org.typelevel" %%% "otel4s-oteljava-trace" % otel4sV % Test, "org.typelevel" %%% "otel4s-oteljava-trace-testkit" % otel4sV % Test, ) diff --git a/core-jvm-tests/src/test/scala/org/http4s/otel4s/OtelMetricsTests.scala b/core-jvm-tests/src/test/scala/org/http4s/otel4s/OtelMetricsTests.scala new file mode 100644 index 0000000..950af04 --- /dev/null +++ b/core-jvm-tests/src/test/scala/org/http4s/otel4s/OtelMetricsTests.scala @@ -0,0 +1,95 @@ +/* + * Copyright 2023 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.otel4s + +import cats.data.OptionT +import cats.effect.IO +import io.opentelemetry.sdk.metrics.data.{MetricData => JMetricData} +import munit.CatsEffectSuite +import org.http4s._ +import org.http4s.server.middleware.Metrics +import org.typelevel.otel4s.metrics.Meter +import org.typelevel.otel4s.oteljava.AttributeConverters._ +import org.typelevel.otel4s.oteljava.testkit.metrics.MetricsTestkit +import io.opentelemetry.api.common.{Attributes => JAttributes} +import scala.jdk.CollectionConverters._ + +class OtelMetricsTests extends CatsEffectSuite { + test("OtelMetrics") { + MetricsTestkit + .inMemory[IO]() + .use { testkit => + for { + meterIO <- testkit.meterProvider.get("meter") + metricsOps <- { + implicit val meter: Meter[IO] = meterIO + OtelMetrics.metricsOps[IO]() + } + _ <- { + val fakeServer = + HttpRoutes[IO](e => OptionT.liftF(e.body.compile.drain.as(Response[IO](Status.Ok)))) + val meteredServer = Metrics[IO](metricsOps)(fakeServer) + + meteredServer + .run(Request[IO](Method.GET)) + .semiflatMap(_.body.compile.drain) + .value + } + metrics <- testkit.collectMetrics[JMetricData] + } yield { + def attributes(attrs: JAttributes): Map[String, String] = + attrs.toScala.toSeq.map(e => e.key.name -> e.value.toString).toMap + + val activeRequests = metrics.find(_.getName == "http.server.active_requests").get + val activeRequestsDataPoints: Map[Map[String, String], Long] = + activeRequests.getLongSumData.getPoints.asScala.toList + .map(e => attributes(e.getAttributes) -> e.getValue) + .toMap + + val requestDuration = metrics.find(_.getName == "http.server.request.duration").get + val requestDurationDataPoints: Map[Map[String, String], Long] = + requestDuration.getHistogramData.getPoints.asScala.toList + .map(e => attributes(e.getAttributes) -> e.getCount) + .toMap + + assertEquals( + activeRequestsDataPoints, + Map( + Map("classifier" -> "") -> 0L + ), + ) + + assertEquals( + requestDurationDataPoints, + Map( + Map( + "classifier" -> "", + "http.phase" -> "headers", + "http.request.method" -> "GET", + ) -> 1L, + Map( + "classifier" -> "", + "http.phase" -> "body", + "http.request.method" -> "GET", + "http.response.status_code" -> "200", + ) -> 1L, + ), + ) + } + } + } +} diff --git a/examples/src/main/scala/example/Http4sExample.scala b/examples/src/main/scala/example/Http4sExample.scala index 632f2ee..098cb76 100644 --- a/examples/src/main/scala/example/Http4sExample.scala +++ b/examples/src/main/scala/example/Http4sExample.scala @@ -17,15 +17,19 @@ package example import cats.effect._ +import cats.effect.syntax.all._ import com.comcast.ip4s._ import fs2.io.net.Network import org.http4s.ember.client.EmberClientBuilder import org.http4s.ember.server.EmberServerBuilder import org.http4s.implicits._ +import org.http4s.otel4s.OtelMetrics import org.http4s.otel4s.middleware.ClientMiddleware import org.http4s.otel4s.middleware.ServerMiddleware import org.http4s.server.Server +import org.http4s.server.middleware.Metrics import org.typelevel.otel4s.Otel4s +import org.typelevel.otel4s.metrics.Meter import org.typelevel.otel4s.oteljava.OtelJava import org.typelevel.otel4s.trace.Tracer @@ -50,15 +54,19 @@ object Http4sExample extends IOApp with Common { def tracer[F[_]](otel: Otel4s[F]): F[Tracer[F]] = otel.tracerProvider.tracer("Http4sExample").get + def meter[F[_]](otel: Otel4s[F]): F[Meter[F]] = + otel.meterProvider.meter("Http4sExample").get + // Our main app resource - def server[F[_]: Async: Network: Tracer]: Resource[F, Server] = + def server[F[_]: Async: Network: Tracer: Meter]: Resource[F, Server] = for { client <- EmberClientBuilder .default[F] .build .map(ClientMiddleware.default.build) + metricsOps <- OtelMetrics.metricsOps[F]().toResource app = ServerMiddleware.default[F].buildHttpApp { - routes(client).orNotFound + Metrics(metricsOps)(routes(client)).orNotFound } sv <- EmberServerBuilder.default[F].withPort(port"8080").withHttpApp(app).build } yield sv @@ -69,7 +77,9 @@ object Http4sExample extends IOApp with Common { .autoConfigured[IO]() .flatMap { otel4s => Resource.eval(tracer(otel4s)).flatMap { implicit T: Tracer[IO] => - server[IO] + Resource.eval(meter(otel4s)).flatMap { implicit M: Meter[IO] => + server[IO] + } } } .use(_ => IO.never)