diff --git a/build.sbt b/build.sbt index 44fb0f29..d87c19bc 100644 --- a/build.sbt +++ b/build.sbt @@ -76,6 +76,7 @@ lazy val otelExtension = (project in file("otel-extension")) excludeDependencies += "io.opentelemetry.javaagent" % "opentelemetry-javaagent-bootstrap", libraryDependencies ++= { zio.map(_ % "provided") ++ + http4s.map(_ % "provided") ++ openTelemetryExtension.map(_ % "provided") ++ opentelemetryExtensionApi ++ openTelemetryMuzzle.map(_ % "provided") ++ @@ -83,7 +84,8 @@ lazy val otelExtension = (project in file("otel-extension")) byteBuddy.map(_ % "provided") ++ akkaTestkit.map(_ % "it,test") ++ scalatest.map(_ % "it,test") ++ - openTelemetryTesting.map(_ % "it,test") + openTelemetryTesting.map(_ % "it,test") ++ + http4sClient.map(_ % "it,test") }, assembly / test := {}, assembly / assemblyJarName := s"${name.value}-assembly.jar", diff --git a/otel-extension/src/it/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/Http4sEmberServerTest.scala b/otel-extension/src/it/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/Http4sEmberServerTest.scala new file mode 100644 index 00000000..84a7d821 --- /dev/null +++ b/otel-extension/src/it/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/Http4sEmberServerTest.scala @@ -0,0 +1,142 @@ +package io.scalac.mesmer.otelextension.instrumentations.http4s.ember.server + +import cats.effect._ +import cats.effect.unsafe.implicits.global +import cats.syntax.all._ +import com.comcast.ip4s._ +import io.opentelemetry.api.common.Attributes +import io.scalac.mesmer.agent.utils.OtelAgentTest +import io.scalac.mesmer.core.config.MesmerPatienceConfig +import org.http4s.HttpApp +import org.http4s.HttpRoutes +import org.http4s.Uri +import org.http4s.ember.client.EmberClientBuilder +import org.http4s.ember.server.EmberServerBuilder +import org.scalatest.BeforeAndAfterEach +import org.scalatest.Inside +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers + +import scala.jdk.CollectionConverters._ + +class Http4sEmberServerTest + extends AnyFreeSpec + with OtelAgentTest + with Matchers + with MesmerPatienceConfig + with BeforeAndAfterEach + with Inside { + import Http4sEmberServerTest._ + + private def service(block: () => Unit): HttpApp[IO] = { + import org.http4s.dsl.io._ + + HttpRoutes + .of[IO] { case GET -> Root => + block() + Ok("") + } + .orNotFound + } + + private def url(address: SocketAddress[Host], path: String = ""): Uri = + Uri.unsafeFromString( + s"http://${Uri.Host.fromIp4sHost(address.host).renderString}:${address.port.value}$path" + ) + + private def server(block: () => Unit) = EmberServerBuilder + .default[IO] + .withHttpApp(service(block)) + .withPort(port"0") + .build + + private val client = EmberClientBuilder.default[IO].build + + private def doGetRootCall(block: () => Unit = () => ()) = { + server(block) + .use(server => + client.use(client => + client + .get(url(server.addressIp4s))(_.status.pure[IO]) + ) + ) + .unsafeRunSync() + () + } + + "http4s ember server" - { + "should record" - { + "requests" - { + "total counter" in { + doGetRootCall() + + assertMetric("mesmer_http4s_ember_server_requests") { data => + inside(data.getLongSumData.getPoints.asScala.toList) { case List(point) => + point.getValue shouldEqual 1 + point.getAttributes.asScalaMap() should contain theSameElementsAs Map( + "method" -> "GET", + "path" -> "/", + "status" -> "200" + ) + } + } + } + + "duration histogram" in { + doGetRootCall() + + assertMetric("mesmer_http4s_ember_server_request_duration_seconds") { data => + inside(data.getHistogramData.getPoints.asScala.toList) { case List(point) => + point.getAttributes.asScalaMap() should contain theSameElementsAs Map( + "method" -> "GET", + "path" -> "/", + "status" -> "200" + ) + } + } + } + + "concurrent counter" in { + val expectedAttributes = Map( + "method" -> "GET", + "path" -> "/" + ) + + val assertZeroConcurrentRequests = () => + assertMetric("mesmer_http4s_ember_server_concurrent_requests") { data => + inside(data.getLongSumData.getPoints.asScala.toList) { case List(point) => + point.getValue shouldEqual 0 + point.getAttributes.asScalaMap() should contain theSameElementsAs expectedAttributes + } + } + + assertZeroConcurrentRequests() + + doGetRootCall { () => + assertMetric("mesmer_http4s_ember_server_concurrent_requests") { data => + inside(data.getLongSumData.getPoints.asScala.toList) { case List(point) => + point.getValue shouldEqual 1 + point.getAttributes.asScalaMap() should contain theSameElementsAs expectedAttributes + } + } + } + + assertZeroConcurrentRequests() + } + } + } + } +} + +object Http4sEmberServerTest { + implicit class AttributesAsMap(attributes: Attributes) { + def asScalaMap(): Map[String, AnyRef] = + attributes + .asMap() + .asScala + .map { case (k, v) => + (k.getKey, v) + } + .toMap + } +} diff --git a/otel-extension/src/main/java/io/scalac/mesmer/otelextension/http4s/ember/server/MesmerHttp4sEmberServerInstrumentationModule.java b/otel-extension/src/main/java/io/scalac/mesmer/otelextension/http4s/ember/server/MesmerHttp4sEmberServerInstrumentationModule.java new file mode 100644 index 00000000..5a170f43 --- /dev/null +++ b/otel-extension/src/main/java/io/scalac/mesmer/otelextension/http4s/ember/server/MesmerHttp4sEmberServerInstrumentationModule.java @@ -0,0 +1,46 @@ +package io.scalac.mesmer.otelextension.http4s.ember.server; + +import com.google.auto.service.AutoService; +import io.opentelemetry.javaagent.extension.instrumentation.InstrumentationModule; +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.tooling.muzzle.InstrumentationModuleMuzzle; +import io.opentelemetry.javaagent.tooling.muzzle.VirtualFieldMappingsBuilder; +import io.opentelemetry.javaagent.tooling.muzzle.references.ClassRef; +import io.scalac.mesmer.otelextension.instrumentations.http4s.ember.server.Http4sEmberServerInstrumentations; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@AutoService(InstrumentationModule.class) +public class MesmerHttp4sEmberServerInstrumentationModule extends InstrumentationModule + implements InstrumentationModuleMuzzle { + public MesmerHttp4sEmberServerInstrumentationModule() { + super("mesmer-http4s-ember-server"); + } + + @Override + public List typeInstrumentations() { + return Collections.singletonList(Http4sEmberServerInstrumentations.serverHelpersRunApp()); + } + + @Override + public List getAdditionalHelperClassNames() { + return List.of( + "io.scalac.mesmer.otelextension.instrumentations.http4s.ember.server.advice.ServerHelpersRunAppAdviceHelper$" + ); + } + + @Override + public Map getMuzzleReferences() { + return Collections.emptyMap(); + } + + @Override + public void registerMuzzleVirtualFields(VirtualFieldMappingsBuilder builder) {} + + @Override + public List getMuzzleHelperClassNames() { + return Collections.emptyList(); + } +} \ No newline at end of file diff --git a/otel-extension/src/main/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/Http4sEmberServerInstrumentations.scala b/otel-extension/src/main/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/Http4sEmberServerInstrumentations.scala new file mode 100644 index 00000000..d2b8b310 --- /dev/null +++ b/otel-extension/src/main/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/Http4sEmberServerInstrumentations.scala @@ -0,0 +1,19 @@ +package io.scalac.mesmer.otelextension.instrumentations.http4s.ember.server + +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation + +import io.scalac.mesmer.agent.util.dsl.matchers.named +import io.scalac.mesmer.agent.util.i13n.Advice +import io.scalac.mesmer.agent.util.i13n.Instrumentation + +object Http4sEmberServerInstrumentations { + + val serverHelpersRunApp: TypeInstrumentation = + Instrumentation(named("org.http4s.ember.server.internal.ServerHelpers$")) + .`with`( + Advice( + named("runApp"), + "io.scalac.mesmer.otelextension.instrumentations.http4s.ember.server.advice.ServerHelpersRunAppAdvice" + ) + ) +} diff --git a/otel-extension/src/main/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/advice/ServerHelpersRunAppAdvice.java b/otel-extension/src/main/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/advice/ServerHelpersRunAppAdvice.java new file mode 100644 index 00000000..27b2e57f --- /dev/null +++ b/otel-extension/src/main/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/advice/ServerHelpersRunAppAdvice.java @@ -0,0 +1,12 @@ +package io.scalac.mesmer.otelextension.instrumentations.http4s.ember.server.advice; + +import cats.data.Kleisli; +import net.bytebuddy.asm.Advice; + +public class ServerHelpersRunAppAdvice { + + @Advice.OnMethodEnter + public static void runAppEnter(@Advice.Argument(value = 4, readOnly = false) Kleisli httpApp) { + httpApp = ServerHelpersRunAppAdviceHelper.withMetrics(httpApp); + } +} diff --git a/otel-extension/src/main/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/advice/ServerHelpersRunAppAdviceHelper.scala b/otel-extension/src/main/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/advice/ServerHelpersRunAppAdviceHelper.scala new file mode 100644 index 00000000..028bdfb9 --- /dev/null +++ b/otel-extension/src/main/scala/io/scalac/mesmer/otelextension/instrumentations/http4s/ember/server/advice/ServerHelpersRunAppAdviceHelper.scala @@ -0,0 +1,64 @@ +package io.scalac.mesmer.otelextension.instrumentations.http4s.ember.server.advice + +import cats.data.Kleisli +import cats.effect.IO +import cats.implicits._ +import io.opentelemetry.api.GlobalOpenTelemetry +import io.opentelemetry.api.common.Attributes +import org.http4s.HttpApp +import org.http4s.Request +import org.http4s.Response + +import scala.util.Try + +object ServerHelpersRunAppAdviceHelper { + + private val meter = GlobalOpenTelemetry.getMeter("mesmer") + + private val requestsTotal = meter + .counterBuilder("mesmer_http4s_ember_server_requests") + .build() + + private val concurrentRequests = meter + .upDownCounterBuilder("mesmer_http4s_ember_server_concurrent_requests") + .build() + + private val requestDuration = meter + .histogramBuilder("mesmer_http4s_ember_server_request_duration_seconds") + .build() + + private def attributesForRequest(request: Request[IO]) = + Attributes.builder().put("method", request.method.name).put("path", request.pathInfo.renderString) + + private def attributesForResponse(response: Response[IO]) = + Attributes.builder().put("status", response.status.code.toString) + + def withMetrics(httpApp: Any): Kleisli[IO, Request[IO], Response[IO]] = + Kleisli[IO, Request[IO], Response[IO]] { request => + val requestAttributes = attributesForRequest(request).build() + val startTime = System.nanoTime() + + concurrentRequests.add(1, requestAttributes) + + httpApp + .asInstanceOf[HttpApp[IO]] + .run(request) + .attemptTap { response => + IO.fromTry(Try { + val allAttributes = requestAttributes.toBuilder + .putAll( + response + .map(attributesForResponse(_).build()) + .getOrElse(Attributes.empty()) + ) + .build() + + requestsTotal.add(1, allAttributes) + + requestDuration.record((System.nanoTime() - startTime) / 1e9d, allAttributes) + + concurrentRequests.add(-1, requestAttributes) + }) + } + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index bcd46734..979092e2 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -9,6 +9,7 @@ object Dependencies { val ByteBuddyVersion = "1.14.2" val CirceVersion = "0.14.5" val CirceYamlVersion = "0.14.2" + val Http4sVersion = "0.23.18" val GoogleAutoServiceVersion = "1.0.1" val LogbackVersion = "1.4.6" @@ -43,6 +44,15 @@ object Dependencies { "dev.zio" %% "zio" % "2.0.10" ) + val http4s = Seq( + "org.http4s" %% "http4s-ember-server" % Http4sVersion, + "org.http4s" %% "http4s-dsl" % Http4sVersion + ) + + val http4sClient = Seq( + "org.http4s" %% "http4s-ember-client" % Http4sVersion + ) + val byteBuddy = Seq( "net.bytebuddy" % "byte-buddy" % ByteBuddyVersion, "net.bytebuddy" % "byte-buddy-agent" % ByteBuddyVersion