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

plugins.metrics.scala Maven / Gradle / Ivy

package otoroshi.plugins.metrics

import java.io.StringWriter
import akka.stream.Materializer
import otoroshi.env.Env
import io.prometheus.client.{Collector, CollectorRegistry}
import io.prometheus.client.exporter.common.TextFormat
import otoroshi.next.plugins.api.{NgPluginCategory, NgPluginVisibility, NgStep}
import otoroshi.script._
import otoroshi.utils.RegexPool
import otoroshi.utils.string.Implicits._
import play.api.libs.json.{JsObject, JsValue, Json}
import play.api.mvc.{Result, Results}
import otoroshi.utils.http.RequestImplicits._
import otoroshi.utils.future.Implicits._

import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success, Try}

// DEPRECATED
class ServiceMetrics extends RequestTransformer {

  override def deprecated: Boolean = true
  override def name: String        = "[DEPRECATED] Service Metrics"

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Monitoring)
  override def steps: Seq[NgStep]                = Seq(NgStep.TransformRequest, NgStep.TransformResponse)

  override def defaultConfig: Option[JsObject] =
    Some(
      Json.obj(
        "ServiceMetrics" -> Json.obj(
          "accessKeyValue" -> "${config.app.health.accessKey}",
          "accessKeyQuery" -> "access_key"
        )
      )
    )

  override def description: Option[String] =
    Some(
      """This plugin expose service metrics in Otoroshi global metrics or on a special URL of the service `/.well-known/otoroshi/metrics`.
      |Metrics are exposed in json or prometheus format depending on the accept header. You can protect it with an access key defined in the configuration
      |
      |This plugin can accept the following configuration
      |
      |```json
      |{
      |  "ServiceMetrics": {
      |    "accessKeyValue": "secret", // if not defined, public access. Can be ${config.app.health.accessKey}
      |    "accessKeyQuery": "access_key"
      |  }
      |}
      |```
      |
      |This plugin is deprecated and can be replaced by a data exporter !
      |    """.stripMargin
    )

  override def transformRequestWithCtx(
      ctx: TransformerRequestContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpRequest]] = {
    (ctx.rawRequest.method, ctx.rawRequest.path) match {
      case ("GET", "/.well-known/otoroshi/plugins/metrics") => {

        val format = ctx.request.getQueryString("format")

        def result(): Future[Either[Result, HttpRequest]] = {
          val filter = Some(s"*otoroshi.service.requests.*.*.${ctx.descriptor.name.slug}*")

          def transformToArray(input: String): JsValue = {
            val metrics = Json.parse(input)
            metrics.as[JsObject].value.toSeq.foldLeft(Json.arr()) {
              case (arr, (key, JsObject(value))) =>
                arr ++ value.toSeq.foldLeft(Json.arr()) {
                  case (arr2, (key2, value2 @ JsObject(_))) =>
                    arr2 ++ Json.arr(value2 ++ Json.obj("name" -> key2, "type" -> key))
                  case (arr2, (key2, value2))               =>
                    arr2
                }
              case (arr, (key, value))           => arr
            }
          }

          if (format.contains("old_json") || format.contains("old")) {
            Left(Results.Ok(env.metrics.jsonExport(filter)).as("application/json")).future
          } else if (format.contains("json")) {
            Left(Results.Ok(transformToArray(env.metrics.jsonExport(filter))).as("application/json")).future
          } else if (format.contains("prometheus") || format.contains("prom")) {
            Left(Results.Ok(env.metrics.prometheusExport(filter)).as("text/plain")).future
          } else if (ctx.request.accepts("application/json")) {
            Left(Results.Ok(transformToArray(env.metrics.jsonExport(filter))).as("application/json")).future
          } else if (ctx.request.accepts("application/prometheus")) {
            Left(Results.Ok(env.metrics.prometheusExport(filter)).as("text/plain")).future
          } else {
            Left(Results.Ok(transformToArray(env.metrics.jsonExport(filter))).as("application/json")).future
          }
        }

        val config    = ctx.configFor("ServiceMetrics")
        val queryName = (config \ "accessKeyQuery").asOpt[String].getOrElse("access_key")
        (config \ "accessKeyValue").asOpt[String] match {
          case None                                                                 => result()
          case Some("${config.app.health.accessKey}")
              if env.healthAccessKey.isDefined && ctx.request
                .getQueryString(queryName)
                .contains(env.healthAccessKey.get) =>
            result()
          case Some(value) if ctx.request.getQueryString(queryName).contains(value) => result()
          case _                                                                    => Left(Results.Unauthorized(Json.obj("error" -> "not authorized !"))).future
        }
      }
      case _ => Right(ctx.otoroshiRequest).future
    }
  }

  override def transformResponseWithCtx(
      ctx: TransformerResponseContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpResponse]] = {
    val start: Long    = ctx.attrs.get(otoroshi.plugins.Keys.RequestStartKey).getOrElse(0L)
    val duration: Long = System.currentTimeMillis() - start

    // env.metrics.counter(s"otoroshi.service.requests.count.total.${ctx.descriptor.name.slug}").inc()
    // env.metrics
    //   .counter(
    //     s"otoroshi.requests.count.total.${ctx.request.theProtocol}.${ctx.request.method.toLowerCase()}.${ctx.rawResponse.status}"
    //   )
    //   .inc()
    // env.metrics.histogram(s"otoroshi.service.requests.duration.seconds.${ctx.descriptor.name.slug}").update(duration)
    // env.metrics
    //   .histogram(
    //     s"otoroshi.requests.duration.seconds.${ctx.request.theProtocol}.${ctx.request.method.toLowerCase()}.${ctx.rawResponse.status}"
    //   )
    //   .update(duration)
    env.metrics.counterInc(s"otoroshi.requests.total")
    env.metrics.histogramUpdate(s"otoroshi.requests.duration.millis", duration)
    env.metrics
      .counterInc(
        s"otoroshi.service.requests.total.${ctx.descriptor.name.slug}.${ctx.request.theProtocol}.${ctx.request.method
          .toLowerCase()}.${ctx.rawResponse.status}"
      )
    env.metrics
      .histogramUpdate(
        s"otoroshi.service.requests.duration.millis.${ctx.descriptor.name.slug}.${ctx.request.theProtocol}.${ctx.request.method
          .toLowerCase()}.${ctx.rawResponse.status}",
        duration
      )
    Right(ctx.otoroshiResponse).future
  }

  override def transformErrorWithCtx(
      ctx: TransformerErrorContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Result] = {
    val start: Long    = ctx.attrs.get(otoroshi.plugins.Keys.RequestStartKey).getOrElse(0L)
    val duration: Long = System.currentTimeMillis() - start
    // env.metrics.counter(s"otoroshi.service.requests.count.total.${ctx.descriptor.name.slug}").inc()
    // env.metrics
    //   .counter(
    //     s"otoroshi.requests.count.total.${ctx.request.theProtocol}.${ctx.request.method.toLowerCase()}.${ctx.rawResponse.status}"
    //   )
    //   .inc()
    // env.metrics.histogram(s"otoroshi.service.requests.duration.seconds.${ctx.descriptor.name.slug}").update(duration)
    // env.metrics
    //   .histogram(
    //     s"otoroshi.requests.duration.seconds.${ctx.request.theProtocol}.${ctx.request.method.toLowerCase()}.${ctx.rawResponse.status}"
    //   )
    //   .update(duration)
    env.metrics.counterInc(s"otoroshi.requests.total")
    env.metrics.histogramUpdate(s"otoroshi.requests.duration.millis", duration)
    env.metrics
      .counterInc(
        s"otoroshi.service.requests.total.${ctx.descriptor.name.slug}.${ctx.request.theProtocol}.${ctx.request.method
          .toLowerCase()}.${ctx.otoroshiResponse.status}"
      )
    env.metrics
      .histogramUpdate(
        s"otoroshi.service.requests.duration.millis.${ctx.descriptor.name.slug}.${ctx.request.theProtocol}.${ctx.request.method
          .toLowerCase()}.${ctx.otoroshiResponse.status}",
        duration
      )
    ctx.otoroshiResult.future
  }
}

object PrometheusSupport {

  // TODO - voir avec Mahtieu
  private[metrics] val registry = new CollectorRegistry()

  def register[T <: Collector](collector: T): T = {
    Try(registry.unregister(collector))
    Try(collector.register(registry))
    collector
  }
}

// DEPRECATED
class PrometheusEndpoint extends RequestSink {

  private val ipRegex = RegexPool.regex(
    "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(:\\d{2,5})?$"
  )

  override def deprecated: Boolean = true
  override def name: String        = "[DEPRECATED] Prometheus Endpoint"

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Monitoring)
  override def steps: Seq[NgStep]                = Seq(NgStep.Sink)

  override def defaultConfig: Option[JsObject] =
    Some(
      Json.obj(
        "PrometheusEndpoint" -> Json.obj(
          "accessKeyValue" -> "${config.app.health.accessKey}",
          "accessKeyQuery" -> "access_key",
          "includeMetrics" -> false
        )
      )
    )

  override def description: Option[String] =
    Some(
      """This plugin exposes metrics collected by `Prometheus Service Metrics` on a `/prometheus` endpoint.
        |You can protect it with an access key defined in the configuration
        |
        |This plugin can accept the following configuration
        |
        |```json
        |{
        |  "PrometheusEndpoint": {
        |    "accessKeyValue": "secret", // if not defined, public access. Can be ${config.app.health.accessKey}
        |    "accessKeyQuery": "access_key",
        |    "includeMetrics": false
        |  }
        |}
        |```
        |
        |This plugin is deprecated and can be replaced by a data exporter !
      """.stripMargin
    )

  override def matches(ctx: RequestSinkContext)(implicit env: Env, ec: ExecutionContext): Boolean = {
    ctx.request.headers.get("Host") match {
      case Some(v) if v == env.adminApiHost && ctx.request.uri.startsWith("/prometheus")                => true
      case Some(v) if env.adminApiDomains.contains(v) && ctx.request.uri.startsWith("/prometheus")      => true
      case Some(v) if ipRegex.matches(ctx.request.theHost) && ctx.request.uri.startsWith("/prometheus") => true
      case _                                                                                            => false
    }
  }

  override def handle(ctx: RequestSinkContext)(implicit env: Env, ec: ExecutionContext): Future[Result] = {

    val config         = ctx.configFor("PrometheusEndpoint")
    val queryName      = (config \ "accessKeyQuery").asOpt[String].getOrElse("access_key")
    val includeMetrics = (config \ "includeMetrics").asOpt[Boolean].getOrElse(false)

    def result(): Future[Result] = {
      val writer  = new StringWriter()
      TextFormat.write004(writer, PrometheusSupport.registry.metricFamilySamples())
      var payload = writer.toString
      if (includeMetrics) {
        payload = payload + env.metrics.prometheusExport(None)
      }
      Results.Ok(payload).as("text/plain").future
    }

    (config \ "accessKeyValue").asOpt[String] match {
      case None                                                                 => result()
      case Some("${config.app.health.accessKey}")
          if env.healthAccessKey.isDefined && ctx.request
            .getQueryString(queryName)
            .contains(env.healthAccessKey.get) =>
        result()
      case Some(value) if ctx.request.getQueryString(queryName).contains(value) => result()
      case _                                                                    => Results.Unauthorized(Json.obj("error" -> "not authorized !")).future
    }
  }
}

// DEPRECATED
class PrometheusServiceMetrics extends RequestTransformer {

  import io.prometheus.client._

  private lazy val requestCounterGlobal = PrometheusSupport.register(
    Counter
      .build()
      .name("otoroshi_requests_count_total")
      .help("How many HTTP requests processed globally")
      .create()
  )

  private lazy val reqDurationGlobal = PrometheusSupport.register(
    Histogram
      .build()
      .name("otoroshi_requests_duration_millis")
      .help("How long it took to process requests globally")
      .buckets(0.1, 0.3, 1.2, 5.0, 10)
      .create()
  )

  private lazy val reqDurationHistogram = PrometheusSupport.register(
    Histogram
      .build()
      .name("otoroshi_service_requests_duration_millis")
      .help("How long it took to process the request on a service, partitioned by status code, protocol, and method")
      .labelNames("code", "method", "protocol", "service")
      .buckets(0.1, 0.3, 1.2, 5.0, 10)
      .create()
  )

  private lazy val reqTotalHistogram = PrometheusSupport.register(
    Counter
      .build()
      .name("otoroshi_service_requests_total")
      .help("How many HTTP requests processed on a service, partitioned by status code, protocol, and method")
      .labelNames("code", "method", "protocol", "service")
      .create()
  )

  private lazy val reqDurationHistogramWithUri = PrometheusSupport.register(
    Histogram
      .build()
      .name("otoroshi_service_requests_wu_duration_millis")
      .help(
        "How long it took to process the request on a service, partitioned by status code, protocol, method and uri"
      )
      .labelNames("code", "method", "protocol", "service", "uri")
      .buckets(0.1, 0.3, 1.2, 5.0, 10)
      .create()
  )

  private lazy val reqTotalHistogramWithUri = PrometheusSupport.register(
    Counter
      .build()
      .name("otoroshi_service_requests_wu_total")
      .help("How many HTTP requests processed on a service, partitioned by status code, protocol, method and uri")
      .labelNames("code", "method", "protocol", "service", "uri")
      .create()
  )

  override def deprecated: Boolean = true
  override def name: String        = "[DEPRECATED] Prometheus Service Metrics"

  override def defaultConfig: Option[JsObject] =
    Some(
      Json.obj(
        "PrometheusServiceMetrics" -> Json.obj(
          "includeUri" -> false
        )
      )
    )

  override def description: Option[String] =
    Some(
      """This plugin collects service metrics and can be used with the `Prometheus Endpoint` (in the Danger Zone) plugin to expose those metrics
        |
        |This plugin can accept the following configuration
        |
        |```json
        |{
        |  "PrometheusServiceMetrics": {
        |    "includeUri": false // include http uri in metrics. WARNING this could impliess performance issues, use at your own risks
        |  }
        |}
        |```
        |
        |This plugin is deprecated and can be replaced by a data exporter !
        |""".stripMargin
    )

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Monitoring)
  override def steps: Seq[NgStep]                = Seq(NgStep.TransformResponse)

  override def transformResponseWithCtx(
      ctx: TransformerResponseContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpResponse]] = {
    val start: Long    = ctx.attrs.get(otoroshi.plugins.Keys.RequestStartKey).getOrElse(0L)
    val duration: Long = System.currentTimeMillis() - start
    val config         = ctx.configFor("PrometheusServiceMetrics")
    val includeUri     = (config \ "includeUri").asOpt[Boolean].getOrElse(false)

    requestCounterGlobal.inc()
    reqDurationGlobal.observe(duration)

    if (includeUri) {
      reqDurationHistogramWithUri
        .labels(
          ctx.otoroshiResponse.status.toString,
          ctx.request.method.toLowerCase(),
          ctx.request.theProtocol,
          ctx.descriptor.name.slug,
          ctx.request.relativeUri
        )
        .observe(duration)
      reqTotalHistogramWithUri
        .labels(
          ctx.otoroshiResponse.status.toString,
          ctx.request.method.toLowerCase(),
          ctx.request.theProtocol,
          ctx.descriptor.name.slug,
          ctx.request.relativeUri
        )
        .inc()
    } else {
      reqDurationHistogram
        .labels(
          ctx.otoroshiResponse.status.toString,
          ctx.request.method.toLowerCase(),
          ctx.request.theProtocol,
          ctx.descriptor.name.slug
        )
        .observe(duration)
      reqTotalHistogram
        .labels(
          ctx.otoroshiResponse.status.toString,
          ctx.request.method.toLowerCase(),
          ctx.request.theProtocol,
          ctx.descriptor.name.slug
        )
        .inc()
    }
    Right(ctx.otoroshiResponse).future
  }

  override def transformErrorWithCtx(
      ctx: TransformerErrorContext
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Result] = {
    val start: Long    = ctx.attrs.get(otoroshi.plugins.Keys.RequestStartKey).getOrElse(0L)
    val duration: Long = System.currentTimeMillis() - start
    val config         = ctx.configFor("PrometheusServiceMetrics")
    val includeUri     = (config \ "includeUri").asOpt[Boolean].getOrElse(false)

    requestCounterGlobal.inc()
    reqDurationGlobal.observe(duration)
    if (includeUri) {
      reqDurationHistogramWithUri
        .labels(
          ctx.otoroshiResponse.status.toString,
          ctx.request.method.toLowerCase(),
          ctx.request.theProtocol,
          ctx.descriptor.name.slug,
          ctx.request.relativeUri
        )
        .observe(duration)
      reqTotalHistogramWithUri
        .labels(
          ctx.otoroshiResponse.status.toString,
          ctx.request.method.toLowerCase(),
          ctx.request.theProtocol,
          ctx.descriptor.name.slug,
          ctx.request.relativeUri
        )
        .inc()
    } else {
      reqDurationHistogram
        .labels(
          ctx.otoroshiResponse.status.toString,
          ctx.request.method.toLowerCase(),
          ctx.request.theProtocol,
          ctx.descriptor.name.slug
        )
        .observe(duration)
      reqTotalHistogram
        .labels(
          ctx.otoroshiResponse.status.toString,
          ctx.request.method.toLowerCase(),
          ctx.request.theProtocol,
          ctx.descriptor.name.slug
        )
        .inc()
    }
    ctx.otoroshiResult.future
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy