All Downloads are FREE. Search and download functionalities are using the official Maven repository.

prometheus4cats.fs2kafka.KafkaMetrics.scala Maven / Gradle / Ivy

/*
 * Copyright 2022 Permutive
 *
 * 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 prometheus4cats.fs2kafka

import cats.effect.kernel.{Resource, Sync}
import cats.syntax.either._
import cats.syntax.flatMap._
import fs2.kafka.{KafkaConsumer, KafkaProducer, TransactionalKafkaProducer}
import org.apache.kafka.common.{MetricName, Metric => KafkaMetric}
import prometheus4cats._

import scala.collection.immutable.{Map, Set}
import scala.jdk.CollectionConverters._

object KafkaMetrics {
  sealed trait ParseError extends Any {
    def metricName: String
    def labelValue: String
  }
  object ParseError {
    case class InvalidName(metricName: String) extends AnyVal with ParseError {
      override def labelValue: String = "invalid_name"
    }
    case class InvalidMetric(metricName: String)
        extends AnyVal
        with ParseError {
      override def labelValue: String = "invalid_metric_type"
    }
    case class InvalidLabels(metricName: String)
        extends AnyVal
        with ParseError {
      override def labelValue: String = "invalid_label_names"
    }
  }

  final private val parseErrorGaugeName: Gauge.Name = "metric_parse_error"

  final private val parseErrorHelp: Metric.Help =
    "Kafka metrics that have been excluded due to invalid strings"

  final private val parseErrorTypeLabel: Label.Name = "parse_error_type"

  final private val parseErrorMetricNameLabel: Label.Name = "metric_name"

  final private val metricsPrefix: Metric.Prefix = "kafka_client"

  final private val consumerGroupLabel: Label.Name = "consumer_group"

  final private val producerNameLabel: Label.Name = "producer_name"

  final private val transactionalProducerLabel: Label.Name = "is_transactional"

  final private val tagFilter: Set[String] = Set("client-id")

  private def transformMetrics[F[_]: Sync](
      extraLabels: Map[Label.Name, String],
      kafkaMetrics: F[Map[MetricName, KafkaMetric]]
  ): F[MetricCollection] = {
    def transformName(kafkaName: MetricName): String =
      s"${kafkaName.group().replace('-', '_').replace("_metrics", "")}_${kafkaName.name().replace('-', '_')}"

    // Doing Label.Name.unsafeFrom here prevents us from having a nasty traverse - which would be harder to read and
    // possibly less performant.
    def transformLabels(
        metricName: MetricName,
        labels: Map[String, String]
    ): Either[ParseError, Map[Label.Name, String]] =
      Either
        .catchNonFatal {
          labels.flatMap { case (key, value) =>
            if (tagFilter.contains(key)) None
            else Some(Label.Name.unsafeFrom(key.replace('-', '_')) -> value)
          } + (Label.Name("metric_group") -> metricName.group())
        }
        .leftMap(_ => ParseError.InvalidLabels(metricName.name()))

    /*
    Kafka client metrics are crazy.

    Calls to `value` to get a double are deprecated. This is because, inexplicably, Kafka metrics are allowed
    to not return a number(!?).

    If the metric does return a number then you have to check the type and cast, note that
    pattern matching won't work here because we are dealing with native Java doubles, unless it's an integer,
    then it's an object(!?).

    If the metric doesn't return a number then an exception is thrown(!?), this should hopefully never
    happen so I'm just letting it fail, so the reason why a non-numeric metric got into the Kafka metric registry can
    be investigated.
     */
    def convertMetricValue[A](
        metric: KafkaMetric
    )(onLong: Long => A, onDouble: Double => A): Either[ParseError, A] = {
      val value = metric.metricValue()

      if (value.isInstanceOf[Double])
        Right(onDouble(value.asInstanceOf[Double]))
      else if (value.isInstanceOf[java.lang.Integer])
        Right(onLong(value.asInstanceOf[java.lang.Integer].longValue()))
      else
        Left(ParseError.InvalidMetric(metric.metricName().name()))
    }

    def createCounter(
        metricName: String,
        help: prometheus4cats.Metric.Help,
        labels: Map[Label.Name, String],
        metric: KafkaMetric,
        col: MetricCollection
    ): Either[ParseError, MetricCollection] =
      Counter.Name
        .from(metricName)
        .leftMap(ParseError.InvalidName(_))
        .flatMap(name =>
          convertMetricValue(metric)(
            col.appendLongCounter(name, help, labels, _),
            col.appendDoubleCounter(name, help, labels, _)
          )
        )

    def createGauge(
        metricName: String,
        help: prometheus4cats.Metric.Help,
        labels: Map[Label.Name, String],
        metric: KafkaMetric,
        col: MetricCollection
    ): Either[ParseError, MetricCollection] =
      Gauge.Name
        .from(metricName)
        .leftMap(ParseError.InvalidName(_))
        .flatMap(name =>
          convertMetricValue(metric)(
            col.appendLongGauge(name, help, labels, _),
            col.appendDoubleGauge(name, help, labels, _)
          )
        )

    /* This rebuilds the labels so that the map contains exactly the same keyset as the labels already registered for the
    metric name.
     */
    def conformLabels(
        expected: Set[String],
        labels: Map[String, String]
    ): Map[String, String] =
      expected.foldLeft(Map.empty[String, String]) { case (map, l) =>
        map.updated(l, labels.getOrElse(l, ""))
      }

    kafkaMetrics.flatMap { metrics =>
      /*
          Reading metric values needs to be blocking because Kafka metrics used a `synchronized` block when reading, this
          is technically blocking, so CE will need to put the operation in its own thread.

          Doing this in a single `blocking` call is better than one for each metric, so we don't end up spawning
          (or reusing a cached) thread for each metric in the Kafka metrics map.
       */
      Sync[F].blocking {
        /*
            Metric labels can sometimes be mis-aligned and therefore we need to initial pass making sure all the labels
            conform before adding them to a metric collection
         */
        val aligned =
          metrics.foldLeft[
            Map[String, Map[MetricName, (KafkaMetric, Map[String, String])]]
          ](Map.empty) { case (acc, (name, metric)) =>
            val transformedName = transformName(name)
            val labels = name.tags().asScala.toMap

            acc.get(transformedName) match {
              case Some(metrics) =>
                val allKeys = metrics.view
                  .flatMap(_._2._2.keySet)
                  .toSet ++ labels.keySet

                val newMetrics = metrics
                  .updated(name, (metric, labels))
                  .map { case (k, (metric, labels)) =>
                    (k, (metric, conformLabels(allKeys, labels)))
                  }

                acc.updated(transformedName, newMetrics)
              case None =>
                acc.updated(transformedName, Map((name, (metric, labels))))
            }
          }

        val (metricCollection, errors) = aligned.foldLeft(
          (MetricCollection.empty, List.empty[ParseError])
        ) { case ((col, parseErrors), (metricName, metrics)) =>
          metrics.foldLeft((col, parseErrors)) {
            case ((col, parseErrors), (name, (metric, labels))) =>
              val newCol = for {
                transformedLabels <- transformLabels(name, labels)
                allLabels = transformedLabels ++ extraLabels
                help = prometheus4cats.Metric.Help
                  .from(name.description())
                  .getOrElse(
                    prometheus4cats.Metric.Help("Metric from kafka")
                  )
                newCol <-
                  if (metricName.endsWith("_total"))
                    createCounter(metricName, help, allLabels, metric, col)
                  else createGauge(metricName, help, allLabels, metric, col)
              } yield newCol

              newCol.fold(
                err => (col, err :: parseErrors),
                (_, parseErrors)
              )

          }

        }

        val errorGaugeValues = IterableUtils
          .groupMapReduce(errors)(identity)(_ => 1L)(_ + _)
          .view
          .map { case (err, v) =>
            v -> (IndexedSeq(
              err.labelValue,
              err.metricName
            ) ++ extraLabels.values)
          }
          .toList

        metricCollection.appendLongGauge(
          parseErrorGaugeName,
          parseErrorHelp,
          IndexedSeq(
            parseErrorTypeLabel,
            parseErrorMetricNameLabel
          ) ++ extraLabels.keys,
          errorGaugeValues
        )

      }

    }
  }

  def registerConsumerCallback[F[_]: Sync, K, V](
      metricFactory: MetricFactory.WithCallbacks[F],
      consumer: KafkaConsumer[F, K, V],
      consumerGroup: String
  ): Resource[F, Unit] = metricFactory
    .withPrefix(metricsPrefix)
    .metricCollectionCallback(
      transformMetrics(
        Map(consumerGroupLabel -> consumerGroup),
        consumer.metrics
      )
    )
    .build

  def registerProducerCallback[F[_]: Sync, K, V](
      metricFactory: MetricFactory.WithCallbacks[F],
      producer: KafkaProducer.Metrics[F, K, V],
      producerName: String
  ): Resource[F, Unit] = metricFactory
    .withPrefix(metricsPrefix)
    .metricCollectionCallback(
      transformMetrics(
        Map(
          producerNameLabel -> producerName,
          transactionalProducerLabel -> false.toString
        ),
        producer.metrics
      )
    )
    .build

  def registerTransactionalProducerCallback[F[_]: Sync, K, V](
      metricFactory: MetricFactory.WithCallbacks[F],
      producer: TransactionalKafkaProducer.Metrics[F, K, V],
      producerName: String
  ): Resource[F, Unit] = metricFactory
    .withPrefix(metricsPrefix)
    .metricCollectionCallback(
      transformMetrics(
        Map(
          producerNameLabel -> producerName,
          transactionalProducerLabel -> true.toString
        ),
        producer.metrics
      )
    )
    .build
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy