Skip to content

Commit

Permalink
Metrics SDK: implement Prometheus exporter
Browse files Browse the repository at this point in the history
  • Loading branch information
bio-aeon committed Oct 11, 2024
1 parent 7278160 commit 1a603df
Show file tree
Hide file tree
Showing 14 changed files with 2,558 additions and 33 deletions.
25 changes: 25 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ lazy val root = tlCrossRootProject
`sdk-exporter-common`,
`sdk-exporter-proto`,
`sdk-exporter-metrics`,
`sdk-exporter-prometheus`,
`sdk-exporter-trace`,
`sdk-exporter`,
`sdk-contrib-aws-resource`,
Expand Down Expand Up @@ -398,6 +399,28 @@ lazy val `sdk-exporter-metrics` =
.settings(munitDependencies)
.settings(scalafixSettings)

lazy val `sdk-exporter-prometheus` =
crossProject(JVMPlatform, JSPlatform, NativePlatform)
.crossType(CrossType.Pure)
.in(file("sdk-exporter/prometheus"))
.dependsOn(
`sdk-exporter-common` % "compile->compile;test->test",
`sdk-metrics` % "compile->compile;test->test"
)
.settings(
name := "otel4s-sdk-exporter-prometheus",
startYear := Some(2024),
libraryDependencies ++= Seq(
"org.http4s" %%% "http4s-dsl" % Http4sVersion,
"org.http4s" %%% "http4s-ember-server" % Http4sVersion
)
)
.jsSettings(scalaJSLinkerSettings)
.nativeEnablePlugins(ScalaNativeBrewedConfigPlugin)
.nativeSettings(scalaNativeSettings)
.settings(munitDependencies)
.settings(scalafixSettings)

lazy val `sdk-exporter-trace` =
crossProject(JVMPlatform, JSPlatform, NativePlatform)
.crossType(CrossType.Pure)
Expand Down Expand Up @@ -429,6 +452,7 @@ lazy val `sdk-exporter` = crossProject(JVMPlatform, JSPlatform, NativePlatform)
sdk,
`sdk-exporter-common`,
`sdk-exporter-metrics`,
`sdk-exporter-prometheus`,
`sdk-exporter-trace`
)
.settings(
Expand Down Expand Up @@ -825,6 +849,7 @@ lazy val unidocs = project
sdk.jvm,
`sdk-exporter-common`.jvm,
`sdk-exporter-metrics`.jvm,
`sdk-exporter-prometheus`.jvm,
`sdk-exporter-trace`.jvm,
`sdk-exporter`.jvm,
`sdk-contrib-aws-resource`.jvm,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/*
* 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.sdk.exporter.prometheus

import scala.annotation.tailrec

private object PrometheusConverter {

private val ReservedSuffixes = List("total", "created", "bucket", "info")

private val UnitMapping = Map(
// Time
"a" -> "years",
"mo" -> "months",
"wk" -> "weeks",
"d" -> "days",
"h" -> "hours",
"min" -> "minutes",
"s" -> "seconds",
"ms" -> "milliseconds",
"us" -> "microseconds",
"ns" -> "nanoseconds",
// Bytes
"By" -> "bytes",
"KiBy" -> "kibibytes",
"MiBy" -> "mebibytes",
"GiBy" -> "gibibytes",
"TiBy" -> "tibibytes",
"KBy" -> "kilobytes",
"MBy" -> "megabytes",
"GBy" -> "gigabytes",
"TBy" -> "terabytes",
// SI
"m" -> "meters",
"V" -> "volts",
"A" -> "amperes",
"J" -> "joules",
"W" -> "watts",
"g" -> "grams",
// Misc
"Cel" -> "celsius",
"Hz" -> "hertz",
"%" -> "percent",
"1" -> "ratio"
)

private val PerMapping = Map(
"s" -> "second",
"min" -> "minute",
"h" -> "hour",
"d" -> "day",
"wk" -> "week",
"mo" -> "month",
"a" -> "year"
)

private val NameIllegalFirstCharRegex = "[^a-zA-Z_:.]"
private val NameIllegalCharsRegex = "[^a-zA-Z0-9_:.]"
private val LabelIllegalFirstCharRegex = "[^a-zA-Z_.]"
private val LabelIllegalCharsRegex = "[^a-zA-Z0-9_.]"
private val UnitIllegalCharsRegex = "[^a-zA-Z0-9_:.]"

private val Replacement = "_"

/** Converts an arbitrary string to Prometheus metric name.
*/
def convertName(name: String): Either[Throwable, String] = {
@tailrec
def removeReservedSuffixes(s: String): String = {
val (updatedS, modified) = ReservedSuffixes.foldLeft((s, false)) { case ((str, modified), suffix) =>
if (str == s"_$suffix" || str == s".$suffix") {
(suffix, modified)
} else if (str.endsWith(s"_$suffix") || str.endsWith(s".$suffix")) {
(str.substring(0, str.length - suffix.length - 1), true)
} else {
(str, modified)
}
}

if (modified) {
removeReservedSuffixes(updatedS)
} else {
updatedS
}
}

errorOnEmpty(name, "Empty string is not a valid metric name").map { _ =>
val (firstChar, rest) = (name.substring(0, 1), name.substring(1))
val nameWithoutIllegalChars =
firstChar.replaceAll(NameIllegalFirstCharRegex, Replacement) + rest.replaceAll(
NameIllegalCharsRegex,
Replacement
)

val nameWithoutReservedSuffixes = removeReservedSuffixes(nameWithoutIllegalChars)
asPrometheusName(nameWithoutReservedSuffixes)
}
}

/** Converts an arbitrary string and OpenTelemetry unit to Prometheus metric name.
*/
def convertName(name: String, unit: String): Either[Throwable, String] = {
convertName(name).map { convertedName =>
val nameWithUnit = if (convertedName.endsWith(s"_$unit")) {
convertedName
} else {
s"${convertedName}_$unit"
}

asPrometheusName(nameWithUnit)
}
}

/** Converts an arbitrary string to Prometheus label name.
*/
def convertLabelName(label: String): Either[Throwable, String] = {
errorOnEmpty(label, "Empty string is not a valid label name").map { _ =>
val (firstChar, rest) = (label.substring(0, 1), label.substring(1))
val labelWithoutIllegalChars =
firstChar.replaceAll(LabelIllegalFirstCharRegex, Replacement) + rest.replaceAll(
LabelIllegalCharsRegex,
Replacement
)

asPrometheusName(labelWithoutIllegalChars)
}
}

/** Converts OpenTelemetry unit names to Prometheus units.
*/
def convertUnitName(unit: String): Either[Throwable, String] = {
errorOnEmpty(unit, "Empty string is not a valid unit name").map { _ =>
val unitWithoutBraces = if (unit.contains("{")) {
unit.replaceAll("\\{[^}]*}", "").trim()
} else {
unit
}

UnitMapping
.getOrElse(
unitWithoutBraces, {
if (unitWithoutBraces.contains("/")) {
val Array(unitFirstPart, unitSecondPart) = unitWithoutBraces.split("/", 2).map(_.trim)
val firstPartPlural = UnitMapping.getOrElse(unitFirstPart, unitFirstPart)
val secondPartSingular = PerMapping.getOrElse(unitSecondPart, unitSecondPart)
if (firstPartPlural.isEmpty) {
refineUnitName(s"per_$secondPartSingular")
} else {
refineUnitName(s"${firstPartPlural}_per_$secondPartSingular")
}
} else {
refineUnitName(unitWithoutBraces)
}
}
)
}
}

private def refineUnitName(unit: String): String = {
def trim(s: String) = s.replaceAll("^[_.]+", "").replaceAll("[_.]+$", "")

@tailrec
def removeReservedSuffixes(s: String): String = {
val (updatedS, modified) = ReservedSuffixes.foldLeft((s, false)) { case ((str, modified), suffix) =>
if (str.endsWith(suffix)) {
(trim(str.substring(0, str.length - suffix.length)), true)
} else {
(str, modified)
}
}

if (modified) {
removeReservedSuffixes(updatedS)
} else {
updatedS
}
}

val unitWithoutIllegalChars = unit.replaceAll(UnitIllegalCharsRegex, Replacement)
removeReservedSuffixes(trim(unitWithoutIllegalChars))
}

private def asPrometheusName(name: String): String = {
name.replace(".", Replacement).replaceAll("_{2,}", Replacement)
}

private def errorOnEmpty(name: String, error: String): Either[IllegalArgumentException, String] = {
Either.cond(name.nonEmpty, name, new IllegalArgumentException(error))
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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.sdk.exporter.prometheus

import cats.MonadError
import cats.syntax.functor._
import org.http4s.HttpRoutes
import org.http4s.Response
import org.http4s.dsl.Http4sDsl
import org.http4s.headers.Accept
import org.typelevel.otel4s.sdk.exporter.prometheus.PrometheusMetricExporter.Defaults
import org.typelevel.otel4s.sdk.metrics.exporter.MetricExporter

object PrometheusHttpRoutes {

/** Creates HTTP routes that will collect metrics and serialize to Prometheus text format on request.
*/
def routes[F[_]: MonadError[*[_], Throwable]](
exporter: MetricExporter.Pull[F],
withoutUnits: Boolean = Defaults.WithoutUnits,
withoutTypeSuffixes: Boolean = Defaults.WithoutTypeSuffixes,
disableScopeInfo: Boolean = Defaults.DisableScopeInfo,
disableTargetInfo: Boolean = Defaults.DisableTargetInfo
): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] {}

import dsl._

val writer = PrometheusWriter.text[F](withoutUnits, withoutTypeSuffixes, disableScopeInfo, disableTargetInfo)

HttpRoutes.of { case req =>
if (req.headers.get[Accept].forall(_.values.exists(_.mediaRange.isText))) {
for {
metrics <- exporter.metricReader.collectAllMetrics
} yield Response().withEntity(writer.write(metrics)).withHeaders("Content-Type" -> writer.contentType)
} else {
NotAcceptable()
}
}
}

}
Loading

0 comments on commit 1a603df

Please sign in to comment.