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

pillars.Metrics.scala Maven / Gradle / Ivy

Go to download

pillars-core is a scala 3 library providing base services for writing backend applications

There is a newer version: 0.4.4
Show newest version
// Copyright (c) 2024-2024 by Raphaël Lemaitre and Contributors
// This software is licensed under the Eclipse Public License v2.0 (EPL-2.0).
// For more information see LICENSE or https://opensource.org/license/epl-2-0

package pillars

import cats.Applicative
import cats.Monad
import cats.syntax.all.*
import java.time.Duration
import java.time.Instant
import org.typelevel.otel4s.Attribute
import org.typelevel.otel4s.AttributeKey
import org.typelevel.otel4s.metrics.BucketBoundaries
import org.typelevel.otel4s.metrics.Counter
import org.typelevel.otel4s.metrics.Histogram
import org.typelevel.otel4s.metrics.Meter
import org.typelevel.otel4s.metrics.UpDownCounter
import sttp.monad.MonadError
import sttp.tapir.AnyEndpoint
import sttp.tapir.model.ServerRequest
import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor
import sttp.tapir.server.metrics.EndpointMetric
import sttp.tapir.server.metrics.Metric
import sttp.tapir.server.metrics.MetricLabels
import sttp.tapir.server.model.ServerResponse

case class Metrics[F[_]: Applicative](meter: Meter[F], metrics: List[Metric[F, ?]]):
    /** The interceptor which can be added to a server's options, to enable metrics collection. */
    def metricsInterceptor(ignoreEndpoints: Seq[AnyEndpoint] = Seq.empty): MetricsRequestInterceptor[F] =
        new MetricsRequestInterceptor[F](metrics, ignoreEndpoints)
end Metrics

object Metrics:

    private lazy val labels: MetricLabels = MetricLabels(
      forRequest = List(
        "http.route"          -> { case (ep, _) => ep.showPathTemplate(showQueryParam = None) },
        "http.request.method" -> { case (_, req) => req.method.method },
        "url.scheme"          -> { case (_, req) => req.uri.scheme.getOrElse("") }
      ),
      forResponse = List(
        "http.response.status"      -> {
            case Right(r) =>
                r.code match
                    case c if c.isInformational => "1xx"
                    case c if c.isSuccess       => "2xx"
                    case c if c.isRedirect      => "3xx"
                    case c if c.isClientError   => "4xx"
                    case c if c.isServerError   => "5xx"
                    case _                      => ""
            case Left(_)  => "5xx"
        },
        "http.response.status_code" -> {
            case Right(r) => r.code.toString
            case Left(_)  => "500"
        },
        "error.type"                -> {
            case Left(ex: PillarsError) => ex.code
            case Left(ex)               => ex.getClass.getName
            case _                      => ""
        }
      )
    )

    /** Using the default labels, registers the following metrics:
    *
    *   - `request_active{path, method}` (up-down-counter)
    *   - `request_total{path, method, status}` (counter)
    *   - `request_duration{path, method, status, phase}` (histogram)
    *
    * Status is by default the status code class (1xx, 2xx, etc.), and phase can be either `headers` or `body` - request duration is
    * measured separately up to the point where the headers are determined, and then once again when the whole response body is complete.
    */
    def init[F[_]: Monad](meter: Meter[F]): F[Metrics[F]] =
        for
            active       <- requestActive(meter)
            total        <- requestTotal(meter)
            duration     <- requestDuration(meter)
            requestSize  <- requestBodySize(meter)
            responseSize <- responseBodySize(meter)
        yield Metrics(meter, List[Metric[F, ?]](active, total, duration))

    def init[F[_]: Applicative](meter: Meter[F], metrics: List[Metric[F, ?]]): F[Metrics[F]] =
        Metrics(meter, metrics).pure[F]

    def noop[F[_]: Applicative]: Metrics[F] = Metrics(Meter.noop[F], Nil)

    private def decreaseCounter[F[_]](
        req: ServerRequest,
        counter: UpDownCounter[F, Long],
        m: MonadError[F],
        ep: AnyEndpoint
    ) =
        m.suspend(counter.dec(asOpenTelemetryAttributes(ep, req)*))

    private def increaseCounter[F[_]](
        req: ServerRequest,
        counter: UpDownCounter[F, Long],
        m: MonadError[F],
        ep: AnyEndpoint
    ) =
        m.suspend(counter.inc(asOpenTelemetryAttributes(ep, req)*))

    private def requestActive[F[_]: Applicative](
        meter: Meter[F]
    ): F[Metric[F, UpDownCounter[F, Long]]] =
        meter
            .upDownCounter[Long]("http.server.active_requests")
            .withDescription("Active HTTP requests")
            .withUnit("{request}")
            .create
            .map: counter =>
                Metric[F, UpDownCounter[F, Long]](
                  counter,
                  onRequest = (req, counter, m) =>
                      m.unit:
                          EndpointMetric()
                              .onEndpointRequest: ep =>
                                  increaseCounter(req, counter, m, ep)
                              .onResponseBody: (ep, _) =>
                                  decreaseCounter(req, counter, m, ep)
                              .onException: (ep, _) =>
                                  decreaseCounter(req, counter, m, ep)
                )

    private def requestTotal[F[_]: Applicative](meter: Meter[F]): F[Metric[F, Counter[F, Long]]] =
        meter
            .counter[Long]("http.server.request.total")
            .withDescription("Total HTTP requests")
            .withUnit("{request}")
            .create
            .map: counter =>
                Metric[F, Counter[F, Long]](
                  counter,
                  onRequest = (req, counter, m) =>
                      m.unit:
                          EndpointMetric()
                              .onResponseBody: (ep, res) =>
                                  m.suspend:
                                      val otLabels =
                                          asOpenTelemetryAttributes(ep, req) ++ asOpenTelemetryAttributes(
                                            Right(res),
                                            None
                                          )
                                      counter.inc(otLabels*)
                              .onException: (ep, ex) =>
                                  m.suspend:
                                      val otLabels =
                                          asOpenTelemetryAttributes(ep, req) ++ asOpenTelemetryAttributes(
                                            Left(ex),
                                            None
                                          )
                                      counter.inc(otLabels*)
                )

    private def requestDuration[F[_]: Applicative](meter: Meter[F]): F[Metric[F, Histogram[F, Long]]] =
        meter
            .histogram[Long]("http.server.request.duration")
            .withDescription("Duration of HTTP requests")
            .withUnit("ms")
            .withExplicitBucketBoundaries(
              BucketBoundaries(5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000)
            )
            .create
            .map: histogram =>
                Metric[F, Histogram[F, Long]](
                  histogram,
                  onRequest = (req, recorder, m) =>
                      m.eval:
                          val requestStart = Instant.now()

                          def duration = Duration.between(requestStart, Instant.now()).toMillis

                          EndpointMetric()
                              .onResponseHeaders: (ep, res) =>
                                  m.suspend:
                                      val otLabels =
                                          asOpenTelemetryAttributes(ep, req) ++ asOpenTelemetryAttributes(
                                            Right(res),
                                            Some(labels.forResponsePhase.headersValue)
                                          )
                                      recorder.record(duration, otLabels*)
                              .onResponseBody: (ep, res) =>
                                  m.suspend:
                                      val otLabels =
                                          asOpenTelemetryAttributes(ep, req) ++ asOpenTelemetryAttributes(
                                            Right(res),
                                            Some(labels.forResponsePhase.bodyValue)
                                          )
                                      recorder.record(duration, otLabels*)
                              .onException: (ep, ex) =>
                                  m.suspend:
                                      val otLabels =
                                          asOpenTelemetryAttributes(ep, req) ++ asOpenTelemetryAttributes(
                                            Left(ex),
                                            None
                                          )
                                      recorder.record(duration, otLabels*)
                )

    private def requestBodySize[F[_]: Applicative](meter: Meter[F]): F[Metric[F, Histogram[F, Long]]] =
        meter
            .histogram[Long]("http.server.request.body.size")
            .withDescription(
              "The size of the request payload body in bytes. This is the number of bytes transferred excluding headers and is often, but not always, present as the Content-Length header."
            )
            .withUnit("By")
            .create
            .map: histogram =>
                Metric[F, Histogram[F, Long]](
                  histogram,
                  onRequest = (req, recorder, m) =>
                      m.eval:
                          EndpointMetric()
                              .onEndpointRequest: ep =>
                                  m.suspend:
                                      val otLabels = asOpenTelemetryAttributes(ep, req)
                                      recorder.record(req.contentLength.getOrElse(0L), otLabels*)
                )

    private def responseBodySize[F[_]: Applicative](meter: Meter[F]): F[Metric[F, Histogram[F, Long]]] =
        meter
            .histogram[Long]("http.server.response.body.size")
            .withDescription(
              "The size of the response payload body in bytes. This is the number of bytes transferred excluding headers."
            )
            .withUnit("By")
            .create
            .map: histogram =>
                Metric[F, Histogram[F, Long]](
                  histogram,
                  onRequest = (req, recorder, m) =>
                      m.eval:
                          EndpointMetric().onResponseBody: (endpoint, response) =>
                              m.eval:
                                  val otLabels = asOpenTelemetryAttributes(endpoint, req)
                                  response.body.foreach:
                                      case Right((_, Some(length: Long))) => recorder.record(length, otLabels*)
                                      case _                              => m.unit(())
                )

    private def asOpenTelemetryAttributes(ep: AnyEndpoint, req: ServerRequest) =
        labels.forRequest
            .foldLeft(List.empty[Attribute[String]]): (b, label) =>
                b :+ Attribute(AttributeKey.string(label._1), label._2(ep, req))

    private def asOpenTelemetryAttributes(res: Either[Throwable, ServerResponse[?]], phase: Option[String]) =
        val attributes = labels.forResponse
            .foldLeft(List.empty[Attribute[String]]): (b, label) =>
                b :+ Attribute(AttributeKey.string(label._1), label._2(res))
        phase match
            case Some(value) => attributes :+ Attribute(AttributeKey.string(labels.forResponsePhase.name), value)
            case None        => attributes
    end asOpenTelemetryAttributes

end Metrics




© 2015 - 2025 Weber Informatics LLC | Privacy Policy