From 169ee392e85e880b9fb54356e67d185af6a71937 Mon Sep 17 00:00:00 2001 From: Maksym Ochenashko Date: Sat, 26 Oct 2024 12:05:17 +0300 Subject: [PATCH] Implement JVM RuntimeMetrics --- .github/workflows/ci.yml | 48 +++++- README.md | 41 +++++ build.sbt | 20 ++- docs/index.md | 43 +++++- .../experimental/metrics/RuntimeMetrics.scala | 55 +++++++ .../metrics/jvm/ClassMetrics.scala | 52 +++++++ .../experimental/metrics/jvm/CpuMetrics.scala | 145 ++++++++++++++++++ .../metrics/jvm/GarbageCollectorMetrics.scala | 126 +++++++++++++++ .../metrics/jvm/MemoryPoolMetrics.scala | 94 ++++++++++++ .../metrics/jvm/ThreadMetrics.scala | 111 ++++++++++++++ .../metrics/RuntimeMetricsSuite.scala | 93 +++++++++++ 11 files changed, 822 insertions(+), 6 deletions(-) create mode 100644 modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/RuntimeMetrics.scala create mode 100644 modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/ClassMetrics.scala create mode 100644 modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/CpuMetrics.scala create mode 100644 modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/GarbageCollectorMetrics.scala create mode 100644 modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/MemoryPoolMetrics.scala create mode 100644 modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/ThreadMetrics.scala create mode 100644 modules/metrics/jvm/src/test/scala/org/typelevel/otel4s/experimental/metrics/RuntimeMetricsSuite.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f60f463..f8ed0d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,15 @@ jobs: matrix: os: [ubuntu-latest] scala: [2.13, 3] - java: [temurin@8] + java: [temurin@8, semeru@21] project: [rootJS, rootJVM, rootNative] + exclude: + - scala: 3 + java: semeru@21 + - project: rootJS + java: semeru@21 + - project: rootNative + java: semeru@21 runs-on: ${{ matrix.os }} timeout-minutes: 60 steps: @@ -52,6 +59,19 @@ jobs: if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' run: sbt +update + - name: Setup Java (semeru@21) + id: setup-java-semeru-21 + if: matrix.java == 'semeru@21' + uses: actions/setup-java@v4 + with: + distribution: semeru + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'semeru@21' && steps.setup-java-semeru-21.outputs.cache-hit == 'false' + run: sbt +update + - name: Check that workflows are up to date run: sbt githubWorkflowCheck @@ -129,6 +149,19 @@ jobs: if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' run: sbt +update + - name: Setup Java (semeru@21) + id: setup-java-semeru-21 + if: matrix.java == 'semeru@21' + uses: actions/setup-java@v4 + with: + distribution: semeru + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'semeru@21' && steps.setup-java-semeru-21.outputs.cache-hit == 'false' + run: sbt +update + - name: Download target directories (2.13, rootJS) uses: actions/download-artifact@v4 with: @@ -240,6 +273,19 @@ jobs: if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' run: sbt +update + - name: Setup Java (semeru@21) + id: setup-java-semeru-21 + if: matrix.java == 'semeru@21' + uses: actions/setup-java@v4 + with: + distribution: semeru + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'semeru@21' && steps.setup-java-semeru-21.outputs.cache-hit == 'false' + run: sbt +update + - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 with: diff --git a/README.md b/README.md index 3f3cd58..0f5b77f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,46 @@ object Main extends IOApp.Simple { } ``` +### 3) `RuntimeMetrics` - the instrumentation for JVM + +The provided metrics: +- Class + - `jvm.class.count` + - `jvm.class.loaded` + - `jvm.class.unloaded` +- CPU + - `jvm.cpu.count` + - `jvm.cpu.recent_utilization` + - `jvm.cpu.time` +- GC + - `jvm.gc.duration` +- Memory + - `jvm.memory.committed` + - `jvm.memory.limit` + - `jvm.memory.used` + - `jvm.memory.used_after_last_gc` +- Thread + - `jvm.thread.count` + +Example: +```scala +import cats.effect.{IO, IOApp} +import org.typelevel.otel4s.experimental.metrics._ +import org.typelevel.otel4s.sdk._ + +object Main extends IOApp.Simple { + def app: IO[Unit] = ??? + + def run: IO[Unit] = + OpenTelemetrySdk.autoConfigured[IO]().use { autoConfigured => + val sdk = autoConfigured.sdk + sdk.meterProvider.get("service.meter").flatMap { implicit meter => + RuntimeMetrics.register[IO].surround(app) + } + } +} +``` + ## Trace - getting started Add the `otel4s-experimental-trace` dependency to the `build.sbt`: @@ -90,6 +130,7 @@ libraryDependencies ++= Seq( The body of a method annotated with `@span` will be wrapped into a span: ```scala +import cats.effect.IO import org.typelevel.otel4s.trace.Tracer import org.typelevel.otel4s.experimental.trace.{attribute, span} diff --git a/build.sbt b/build.sbt index aa28ffc..ba91f7a 100644 --- a/build.sbt +++ b/build.sbt @@ -23,10 +23,17 @@ ThisBuild / githubWorkflowBuildPostamble ++= Seq( ) ) +ThisBuild / githubWorkflowJavaVersions := Seq( + JavaSpec.temurin("8"), + JavaSpec.semeru("21") +) + +ThisBuild / resolvers ++= Resolver.sonatypeOssRepos("snapshots") + val Versions = new { - val Scala213 = "2.13.14" + val Scala213 = "2.13.15" val Scala3 = "3.3.3" - val Otel4s = "0.10.0" + val Otel4s = "0.11-7d84643-SNAPSHOT" val Munit = "1.0.0" val MUnitScalaCheck = "1.0.0-M11" // we aren't ready for Scala Native 0.5.x val MUnitCatsEffect = "2.0.0" @@ -51,7 +58,11 @@ lazy val metrics = crossProject(JVMPlatform, JSPlatform, NativePlatform) ) ) .jvmSettings( - Test / fork := true + Test / fork := true, + libraryDependencies ++= Seq( + "org.typelevel" %%% "otel4s-semconv-metrics" % Versions.Otel4s, + "org.typelevel" %%% "otel4s-semconv-metrics-experimental" % Versions.Otel4s % Test, + ) ) lazy val trace = crossProject(JVMPlatform, JSPlatform, NativePlatform) @@ -98,7 +109,8 @@ lazy val docs = project scalacOptions += "-Ymacro-annotations", tlFatalWarnings := false, libraryDependencies ++= Seq( - "org.typelevel" %% "otel4s-oteljava" % Versions.Otel4s + "org.typelevel" %% "otel4s-oteljava" % Versions.Otel4s, + "org.typelevel" %% "otel4s-sdk" % Versions.Otel4s ) ) diff --git a/docs/index.md b/docs/index.md index 6183b0b..3f75721 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,6 +77,46 @@ object Main extends IOApp.Simple { } ``` +### 3) `RuntimeMetrics` - the instrumentation for JVM + +The provided metrics: +- Class + - `jvm.class.count` + - `jvm.class.loaded` + - `jvm.class.unloaded` +- CPU + - `jvm.cpu.count` + - `jvm.cpu.recent_utilization` + - `jvm.cpu.time` +- GC + - `jvm.gc.duration` +- Memory + - `jvm.memory.committed` + - `jvm.memory.limit` + - `jvm.memory.used` + - `jvm.memory.used_after_last_gc` +- Thread + - `jvm.thread.count` + +Example: +```scala mdoc:reset:silent +import cats.effect.{IO, IOApp} +import org.typelevel.otel4s.experimental.metrics._ +import org.typelevel.otel4s.sdk._ + +object Main extends IOApp.Simple { + def app: IO[Unit] = ??? + + def run: IO[Unit] = + OpenTelemetrySdk.autoConfigured[IO]().use { autoConfigured => + val sdk = autoConfigured.sdk + sdk.meterProvider.get("service.meter").flatMap { implicit meter => + RuntimeMetrics.register[IO].surround(app) + } + } +} +``` + ## Trace - getting started Add the `otel4s-experimental-trace` dependency to the `build.sbt`: @@ -89,7 +129,8 @@ libraryDependencies ++= Seq( ### 1) `@span` annotation The body of a method annotated with `@span` will be wrapped into a span: -```scala mdoc:silent +```scala mdoc:reset:silent +import cats.effect.IO import org.typelevel.otel4s.trace.Tracer import org.typelevel.otel4s.experimental.trace.{attribute, span} diff --git a/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/RuntimeMetrics.scala b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/RuntimeMetrics.scala new file mode 100644 index 0000000..68feaf6 --- /dev/null +++ b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/RuntimeMetrics.scala @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Typelevel + * + * 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.typelevel.otel4s.experimental.metrics + +import cats.effect.Async +import cats.effect.Resource +import cats.effect.std.Console +import org.typelevel.otel4s.experimental.metrics.jvm._ +import org.typelevel.otel4s.metrics.Meter + +object RuntimeMetrics { + + /** Registers the JVM runtime metrics: + * - Class + * - `jvm.class.count` + * - `jvm.class.loaded` + * - `jvm.class.unloaded` + * - CPU + * - `jvm.cpu.count` + * - `jvm.cpu.recent_utilization` + * - `jvm.cpu.time` + * - GC + * - `jvm.gc.duration` + * - Memory + * - `jvm.memory.committed` + * - `jvm.memory.limit` + * - `jvm.memory.used` + * - `jvm.memory.used_after_last_gc` + * - Thread + * - `jvm.thread.count` + */ + def register[F[_]: Async: Meter: Console]: Resource[F, Unit] = + for { + _ <- ClassMetrics.register + _ <- CpuMetrics.register + _ <- GarbageCollectorMetrics.register + _ <- MemoryPoolMetrics.register + _ <- ThreadMetrics.register + } yield () + +} diff --git a/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/ClassMetrics.scala b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/ClassMetrics.scala new file mode 100644 index 0000000..240b56f --- /dev/null +++ b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/ClassMetrics.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2024 Typelevel + * + * 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.typelevel.otel4s.experimental.metrics.jvm + +import cats.effect.Resource +import cats.effect.Sync +import cats.effect.syntax.resource._ +import cats.syntax.flatMap._ +import cats.syntax.functor._ +import org.typelevel.otel4s.metrics.Meter +import org.typelevel.otel4s.semconv.metrics.JvmMetrics + +import java.lang.management.ManagementFactory + +/** @see + * [[https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#jvm-classes]] + */ +object ClassMetrics { + + def register[F[_]: Sync: Meter]: Resource[F, Unit] = + Sync[F].delay(ManagementFactory.getClassLoadingMXBean).toResource.flatMap { bean => + Meter[F].batchCallback.of( + JvmMetrics.ClassCount.createObserver[F, Long], + JvmMetrics.ClassLoaded.createObserver[F, Long], + JvmMetrics.ClassUnloaded.createObserver[F, Long] + ) { (classCount, classLoaded, classUnloaded) => + for { + count <- Sync[F].delay(bean.getTotalLoadedClassCount) + loaded <- Sync[F].delay(bean.getLoadedClassCount) + unloaded <- Sync[F].delay(bean.getUnloadedClassCount) + _ <- classCount.record(count) + _ <- classLoaded.record(loaded.toLong) + _ <- classUnloaded.record(unloaded) + } yield () + } + } + +} diff --git a/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/CpuMetrics.scala b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/CpuMetrics.scala new file mode 100644 index 0000000..de83bed --- /dev/null +++ b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/CpuMetrics.scala @@ -0,0 +1,145 @@ +/* + * Copyright 2024 Typelevel + * + * 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.typelevel.otel4s.experimental.metrics.jvm + +import cats.effect.Resource +import cats.effect.Sync +import cats.effect.std.Console +import cats.effect.syntax.resource._ +import cats.syntax.applicativeError._ +import cats.syntax.flatMap._ +import cats.syntax.foldable._ +import cats.syntax.functor._ +import org.typelevel.otel4s.metrics.Meter +import org.typelevel.otel4s.semconv.metrics.JvmMetrics + +import java.lang.management.ManagementFactory +import java.lang.management.OperatingSystemMXBean +import java.lang.reflect.InvocationTargetException +import java.util.concurrent.TimeUnit + +/** @see + * [[https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#jvm-cpu]] + */ +object CpuMetrics { + private val NanosPerSecond = TimeUnit.SECONDS.toNanos(1) + + private val TargetBeans = List( + // Hotspot + "com.sun.management.OperatingSystemMXBean", + // OpenJ9 + "com.ibm.lang.management.OperatingSystemMXBean" + ) + + def register[F[_]: Sync: Meter: Console]: Resource[F, Unit] = { + + def cpuTime(accessor: F[Long]) = + JvmMetrics.CpuTime.createWithCallback[F, Double] { measurement => + for { + cpuTimeNanos <- accessor + _ <- Option(cpuTimeNanos) + .filter(_ >= 0) + .traverse_(time => measurement.record(time.toDouble / NanosPerSecond)) + } yield () + } + + def recentUtilization(accessor: F[Double]) = + JvmMetrics.CpuRecentUtilization.createWithCallback[F, Double] { measurement => + for { + cpuUsage <- accessor + _ <- Option(cpuUsage).filter(_ >= 0).traverse_(usage => measurement.record(usage)) + } yield () + } + + val cpuCount = JvmMetrics.CpuCount.createWithCallback[F, Long] { measurement => + for { + availableProcessors <- Sync[F].delay(Runtime.getRuntime.availableProcessors()) + _ <- measurement.record(availableProcessors.toLong) + } yield () + } + + for { + bean <- Sync[F].delay(ManagementFactory.getOperatingSystemMXBean).toResource + + cpuTimeAccessor <- makeAccessor( + bean, + "getProcessCpuTime", + classOf[java.lang.Long], + Long.unbox + ).toResource + + recentUtilizationAccessor <- makeAccessor( + bean, + "getProcessCpuLoad", + classOf[java.lang.Double], + Double.unbox + ).toResource + + _ <- cpuTimeAccessor.traverse_(accessor => cpuTime(accessor)) + _ <- recentUtilizationAccessor.traverse_(accessor => recentUtilization(accessor)) + _ <- cpuCount + } yield () + } + + private def makeAccessor[F[_]: Sync: Console, J, A]( + bean: OperatingSystemMXBean, + methodName: String, + target: Class[J], + cast: J => A + ): F[Option[F[A]]] = { + def tryCreateAccessor(targetClass: String): F[Option[F[A]]] = + Sync[F] + .delay { + val osBeanClass = Class.forName(targetClass) + osBeanClass.cast(bean) + val method = osBeanClass.getDeclaredMethod(methodName) + method.setAccessible(true) + + def invoke() = + cast(target.cast(method.invoke(bean))) + + invoke() // make sure it works + + Option(Sync[F].delay(invoke())) + } + .handleErrorWith { + case _: ClassNotFoundException | _: ClassCastException | _: NoSuchMethodException => + Sync[F].pure(None) + + case _: IllegalAccessException | _: InvocationTargetException => + Console[F] + .errorln( + s"CpuMetrics: cannot invoke [$methodName] in [$targetClass]. This metric will not be reported." + ) + .as(None) + + case _: Throwable => + Sync[F].pure(None) + } + + Sync[F].tailRecM(TargetBeans) { + case head :: tail => + for { + accessor <- tryCreateAccessor(head) + } yield accessor.map(Option(_)).toRight(tail) + + case Nil => + Sync[F].pure(Right(None)) + } + } + +} diff --git a/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/GarbageCollectorMetrics.scala b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/GarbageCollectorMetrics.scala new file mode 100644 index 0000000..724affc --- /dev/null +++ b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/GarbageCollectorMetrics.scala @@ -0,0 +1,126 @@ +/* + * Copyright 2024 Typelevel + * + * 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.typelevel.otel4s.experimental.metrics.jvm + +import cats.effect.Async +import cats.effect.Resource +import cats.effect.Sync +import cats.effect.std.Console +import cats.effect.std.Dispatcher +import cats.effect.syntax.resource._ +import cats.syntax.applicativeError._ +import cats.syntax.either._ +import cats.syntax.flatMap._ +import cats.syntax.foldable._ +import cats.syntax.functor._ +import com.sun.management.GarbageCollectionNotificationInfo +import org.typelevel.otel4s.Attributes +import org.typelevel.otel4s.metrics.BucketBoundaries +import org.typelevel.otel4s.metrics.Histogram +import org.typelevel.otel4s.metrics.Meter +import org.typelevel.otel4s.semconv.attributes.JvmAttributes +import org.typelevel.otel4s.semconv.metrics.JvmMetrics + +import java.lang.management.GarbageCollectorMXBean +import java.lang.management.ManagementFactory +import java.util.concurrent.TimeUnit +import javax.management.Notification +import javax.management.NotificationEmitter +import javax.management.NotificationListener +import javax.management.openmbean.CompositeData +import scala.jdk.CollectionConverters._ + +/** @see + * [[https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#jvm-garbage-collection]] + */ +object GarbageCollectorMetrics { + private val Buckets = BucketBoundaries(0.01, 0.1, 1, 10) + private val MillisPerSecond = TimeUnit.MILLISECONDS.toNanos(1) + + def register[F[_]: Async: Meter: Console]: Resource[F, Unit] = + Async[F] + .delay(isNotificationAvailable) + .toResource + .ifM(create[F], warn[F].toResource) + + private def create[F[_]: Async: Meter]: Resource[F, Unit] = + for { + beans <- Async[F].delay(ManagementFactory.getGarbageCollectorMXBeans).toResource + dispatcher <- Dispatcher.sequential[F] + histogram <- JvmMetrics.GcDuration.create[F, Double](Buckets).toResource + _ <- beans.asScala.toList.traverse_ { + case e: NotificationEmitter => register(histogram, dispatcher, e) + case _ => Resource.unit[F] + } + } yield () + + private def warn[F[_]: Console]: F[Unit] = + Console[F].errorln( + "GarbageCollectorMetrics: " + + "the com.sun.management.GarbageCollectionNotificationInfo class is missing. " + + "GC metrics will not be reported." + ) + + private def register[F[_]: Sync]( + histogram: Histogram[F, Double], + dispatcher: Dispatcher[F], + emitter: NotificationEmitter + ): Resource[F, Unit] = { + def add: F[NotificationListener] = Sync[F].delay { + val listener = new NotificationListener { + def handleNotification(notification: Notification, handback: Any): Unit = { + val info = GarbageCollectionNotificationInfo.from( + notification.getUserData.asInstanceOf[CompositeData] + ) + + val duration = info.getGcInfo.getDuration.toDouble / MillisPerSecond + + val attributes = Attributes( + JvmAttributes.JvmGcName(info.getGcName), + JvmAttributes.JvmGcAction(info.getGcAction) + ) + + dispatcher.unsafeRunSync(histogram.record(duration, attributes)) + } + } + + emitter.addNotificationListener( + listener, + _.getType == GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION, + null + ) + + listener + } + + Resource + .make(add)(listener => Sync[F].delay(emitter.removeNotificationListener(listener)).voidError) + .void + } + + private def isNotificationAvailable: Boolean = + Either + .catchOnly[ClassNotFoundException]( + Class.forName( + "com.sun.management.GarbageCollectionNotificationInfo", + false, + classOf[GarbageCollectorMXBean].getClassLoader + ) + ) + .fold(_ => false, _ => true) + +} diff --git a/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/MemoryPoolMetrics.scala b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/MemoryPoolMetrics.scala new file mode 100644 index 0000000..227f4ae --- /dev/null +++ b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/MemoryPoolMetrics.scala @@ -0,0 +1,94 @@ +/* + * Copyright 2024 Typelevel + * + * 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.typelevel.otel4s.experimental.metrics.jvm + +import cats.Monad +import cats.effect.Resource +import cats.effect.Sync +import cats.effect.syntax.resource._ +import cats.syntax.flatMap._ +import cats.syntax.foldable._ +import cats.syntax.functor._ +import org.typelevel.otel4s.Attributes +import org.typelevel.otel4s.metrics.Meter +import org.typelevel.otel4s.metrics.ObservableMeasurement +import org.typelevel.otel4s.semconv.attributes.JvmAttributes +import org.typelevel.otel4s.semconv.metrics.JvmMetrics + +import java.lang.management.ManagementFactory +import java.lang.management.MemoryPoolMXBean +import java.lang.management.MemoryType +import java.lang.management.MemoryUsage +import scala.jdk.CollectionConverters._ + +/** @see + * [[https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#jvm-memory]] + */ +object MemoryPoolMetrics { + + def register[F[_]: Sync: Meter]: Resource[F, Unit] = + Sync[F].delay(ManagementFactory.getMemoryPoolMXBeans).toResource.flatMap { jBeans => + val beans = jBeans.asScala.toList + + Meter[F].batchCallback.of( + JvmMetrics.MemoryUsed.createObserver[F, Long], + JvmMetrics.MemoryCommitted.createObserver[F, Long], + JvmMetrics.MemoryLimit.createObserver[F, Long], + JvmMetrics.MemoryUsedAfterLastGc.createObserver[F, Long] + ) { (memoryUsed, memoryCommitted, memoryLimit, memoryUsedAfterLastGC) => + for { + _ <- record(memoryUsed, beans, _.getUsage, _.getUsed) + _ <- record(memoryCommitted, beans, _.getUsage, _.getCommitted) + _ <- record(memoryLimit, beans, _.getUsage, _.getMax) + _ <- record(memoryUsedAfterLastGC, beans, _.getCollectionUsage, _.getUsed) + } yield () + } + } + + private def record[F[_]: Monad]( + measurement: ObservableMeasurement[F, Long], + beans: List[MemoryPoolMXBean], + focusUsage: MemoryPoolMXBean => MemoryUsage, + focusValue: MemoryUsage => Long + ): F[Unit] = + beans.traverse_ { bean => + val memoryType = bean.getType match { + case MemoryType.HEAP => JvmAttributes.JvmMemoryTypeValue.Heap + case MemoryType.NON_HEAP => JvmAttributes.JvmMemoryTypeValue.NonHeap + } + + val attributes = Attributes( + JvmAttributes.JvmMemoryPoolName(bean.getName), + JvmAttributes.JvmMemoryType(memoryType.value) + ) + + // could be null in some cases + Option(focusUsage(bean)) match { + case Some(usage) => + val value = focusValue(usage) + if (value != -1L) { + measurement.record(value, attributes) + } else { + Monad[F].unit + } + + case None => + Monad[F].unit + } + } + +} diff --git a/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/ThreadMetrics.scala b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/ThreadMetrics.scala new file mode 100644 index 0000000..6dd2e12 --- /dev/null +++ b/modules/metrics/jvm/src/main/scala/org/typelevel/otel4s/experimental/metrics/jvm/ThreadMetrics.scala @@ -0,0 +1,111 @@ +/* + * Copyright 2024 Typelevel + * + * 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.typelevel.otel4s.experimental.metrics.jvm + +import cats.effect.Resource +import cats.effect.Sync +import cats.effect.syntax.resource._ +import cats.syntax.either._ +import cats.syntax.flatMap._ +import cats.syntax.foldable._ +import cats.syntax.functor._ +import org.typelevel.otel4s.Attributes +import org.typelevel.otel4s.metrics.Meter +import org.typelevel.otel4s.metrics.ObservableMeasurement +import org.typelevel.otel4s.semconv.attributes.JvmAttributes +import org.typelevel.otel4s.semconv.metrics.JvmMetrics + +import java.lang.invoke.MethodHandle +import java.lang.invoke.MethodHandles +import java.lang.invoke.MethodType +import java.lang.management.ManagementFactory +import java.lang.management.ThreadInfo +import java.lang.management.ThreadMXBean +import java.util.Locale + +/** @see + * [[https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/#jvm-threads]] + */ +object ThreadMetrics { + + def register[F[_]: Sync: Meter]: Resource[F, Unit] = + for { + bean <- Sync[F].delay(ManagementFactory.getThreadMXBean).toResource + threadInfoHandleOpt <- Sync[F].delay(java9ThreadInfoHandle).toResource + _ <- JvmMetrics.ThreadCount.createWithCallback[F, Long]( + threadInfoHandleOpt.fold(java8[F](bean))(handle => java9(bean, handle)) + ) + } yield () + + private def java8[F[_]: Sync]( + bean: ThreadMXBean + )(measurement: ObservableMeasurement[F, Long]): F[Unit] = + for { + threadCount <- Sync[F].delay(bean.getThreadCount) + daemonThreadCount <- Sync[F].delay(bean.getDaemonThreadCount) + + _ <- measurement.record( + daemonThreadCount, + JvmAttributes.JvmThreadDaemon(true) + ) + + _ <- measurement.record( + threadCount.toLong - daemonThreadCount, + JvmAttributes.JvmThreadDaemon(false) + ) + } yield () + + private def java9[F[_]: Sync]( + bean: ThreadMXBean, + handle: MethodHandle + )(measurement: ObservableMeasurement[F, Long]): F[Unit] = { + def attributes(info: ThreadInfo): Attributes = { + val isDaemon = handle.invoke(info).asInstanceOf[Boolean] + val state = info.getThreadState.name().toLowerCase(Locale.ROOT) + + Attributes( + JvmAttributes.JvmThreadDaemon(isDaemon), + JvmAttributes.JvmThreadState(state) + ) + } + + for { + stats <- Sync[F].delay { + val infos = bean.getThreadInfo(bean.getAllThreadIds) + + infos + .filter(_ ne null) + .toList + .groupBy(attributes) + .map { case (key, values) => (key, values.size) } + .toList + } + + _ <- stats.traverse_ { case (attributes, count) => + measurement.record(count.toLong, attributes) + } + } yield () + } + + private def java9ThreadInfoHandle: Option[MethodHandle] = + Either.catchNonFatal { + MethodHandles + .publicLookup() + .findVirtual(classOf[ThreadInfo], "isDaemon", MethodType.methodType(classOf[Boolean])) + }.toOption + +} diff --git a/modules/metrics/jvm/src/test/scala/org/typelevel/otel4s/experimental/metrics/RuntimeMetricsSuite.scala b/modules/metrics/jvm/src/test/scala/org/typelevel/otel4s/experimental/metrics/RuntimeMetricsSuite.scala new file mode 100644 index 0000000..6dbfb39 --- /dev/null +++ b/modules/metrics/jvm/src/test/scala/org/typelevel/otel4s/experimental/metrics/RuntimeMetricsSuite.scala @@ -0,0 +1,93 @@ +/* + * Copyright 2024 Typelevel + * + * 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.typelevel.otel4s.experimental.metrics + +import cats.effect.IO +import munit.CatsEffectSuite +import org.typelevel.otel4s.metrics.Meter +import org.typelevel.otel4s.sdk.metrics.data.MetricData +import org.typelevel.otel4s.sdk.testkit.metrics.MetricsTestkit +import org.typelevel.otel4s.semconv.MetricSpec +import org.typelevel.otel4s.semconv.Requirement +import org.typelevel.otel4s.semconv.metrics.JvmMetrics + +class RuntimeMetricsSuite extends CatsEffectSuite { + + test("specification check") { + val specs = List( + // class + JvmMetrics.ClassCount, + JvmMetrics.ClassLoaded, + JvmMetrics.ClassUnloaded, + // cpu + JvmMetrics.CpuTime, + JvmMetrics.CpuCount, + JvmMetrics.CpuRecentUtilization, + // gc, + JvmMetrics.GcDuration, + // memory + JvmMetrics.MemoryUsed, + JvmMetrics.MemoryCommitted, + JvmMetrics.MemoryLimit, + JvmMetrics.MemoryUsedAfterLastGc, + // thread + JvmMetrics.ThreadCount + ) + + assertEquals(specs.sortBy(_.name), JvmMetrics.specs.sortBy(_.name)) + + MetricsTestkit.inMemory[IO]().use { testkit => + testkit.meterProvider.get("meter").flatMap { implicit meter: Meter[IO] => + RuntimeMetrics.register[IO].surround { + for { + _ <- IO.delay(System.gc()) + metrics <- testkit.collectMetrics + } yield specs.foreach(spec => specTest(metrics, spec)) + } + } + } + } + + private def specTest(metrics: List[MetricData], spec: MetricSpec): Unit = { + val metric = metrics.find(_.name == spec.name) + assert( + metric.isDefined, + s"${spec.name} metric is missing. Available [${metrics.map(_.name).mkString(", ")}]", + ) + + val clue = s"[${spec.name}] has a mismatched property" + + metric.foreach { md => + assertEquals(md.name, spec.name, clue) + assertEquals(md.description, Some(spec.description), clue) + assertEquals(md.unit, Some(spec.unit), clue) + + val required = spec.attributeSpecs + .filter(_.requirement.level == Requirement.Level.Required) + .map(_.key) + .toSet + + val current = md.data.points.toVector + .flatMap(_.attributes.map(_.key)) + .filter(key => required.contains(key)) + .toSet + + assertEquals(current, required, clue) + } + } + +}