diff --git a/build.sbt b/build.sbt index 9717ce62c..47e39f227 100644 --- a/build.sbt +++ b/build.sbt @@ -776,6 +776,7 @@ lazy val docs = project oteljava, sdk.jvm, `sdk-exporter`.jvm, + `sdk-exporter-prometheus`.jvm, `sdk-contrib-aws-resource`.jvm, `sdk-contrib-aws-xray`.jvm, `sdk-contrib-aws-xray-propagator`.jvm diff --git a/docs/sdk/configuration.md b/docs/sdk/configuration.md index 0c3ef731d..688f4bb33 100644 --- a/docs/sdk/configuration.md +++ b/docs/sdk/configuration.md @@ -74,6 +74,7 @@ To disable some detectors, use `-Dotel.otel4s.resource.detectors.disabled=host,o Options supported out of the box: - `otlp` - requires `otel4s-sdk-exporter` dependency. +- `prometheus` - requires `otel4s-sdk-exporter-prometheus` dependency. - `console` - prints metrics to stdout. It's mainly used for testing and debugging. - `none` - means no autoconfigured exporter. @@ -102,6 +103,20 @@ Target-specific properties are prioritized. E.g. `otel.exporter.otlp.metrics.end | otel.exporter.otlp.metrics.compression | OTEL\\_EXPORTER\\_OTLP\\_METRICS\\_COMPRESSION | The compression type to use on OTLP trace requests. Options include gzip. By default, no compression will be used. | | otel.exporter.otlp.metrics.timeout | OTEL\\_EXPORTER\\_OTLP\\_METRICS\\_TIMEOUT | The maximum waiting time to send each OTLP trace batch. Default is `10 seconds`. | +### Prometheus exporter + +The exporter launches an HTTP server which responds to the HTTP requests with Prometheus metrics in the appropriate format. + +| System property | Environment variable | Description | +|----------------------------------------------|-------------------------------------------------------|------------------------------------------------------------------------------------------| +| otel.exporter.prometheus.host | OTEL\\_EXPORTER\\_PROMETHEUS\_HOST | The host that metrics are served on. Default is `localhost`. | +| otel.exporter.prometheus.port | OTEL\\_EXPORTER\\_PROMETHEUS\_PORT | The port that metrics are served on. Default is `9464`. | +| otel.exporter.prometheus.default.aggregation | OTEL\\_EXPORTER\\_PROMETHEUS\_DEFAULT\\_AGGREGATION | Default aggregation as a function of instrument kind. Default is `default`. | +| otel.exporter.prometheus.without.units | OTEL\\_EXPORTER\\_PROMETHEUS\_WITHOUT\\_UNITS | If metrics are produced without a unit suffix. Default is `false`. | +| otel.exporter.prometheus.without.type.suffix | OTEL\\_EXPORTER\\_PROMETHEUS\_WITHOUT\\_TYPE\\_SUFFIX | If metrics are produced without a type suffix. Default is `false`. | +| otel.exporter.prometheus.without.scope.info | OTEL\\_EXPORTER\\_PROMETHEUS\_WITHOUT\\_SCOPE\\_INFO | If metrics are produced without a scope info metric or scope labels. Default is `false`. | +| otel.exporter.prometheus.without.target.info | OTEL\\_EXPORTER\\_PROMETHEUS\_WITHOUT\\_TARGET\\_INFO | If metrics are produced without a target info metric. Default is `false`. | + ### Period metric reader Period metric reader pushes metrics to the push-based exporters (e.g. OTLP) over a fixed interval. diff --git a/docs/sdk/directory.conf b/docs/sdk/directory.conf index f12fadfa7..52f524011 100644 --- a/docs/sdk/directory.conf +++ b/docs/sdk/directory.conf @@ -3,6 +3,7 @@ laika.title = SDK laika.navigationOrder = [ overview.md configuration.md + prometheus-exporter.md aws-resource-detectors.md aws-xray.md aws-xray-propagator.md diff --git a/docs/sdk/prometheus-exporter.md b/docs/sdk/prometheus-exporter.md new file mode 100644 index 000000000..27ff5d6b5 --- /dev/null +++ b/docs/sdk/prometheus-exporter.md @@ -0,0 +1,344 @@ +# Prometheus Exporter + +The exporter exports metrics in a prometheus-compatible format, so Prometheus can scrape metrics from the HTTP server. +You can either allow exporter to launch its own server or add Prometheus routes to the existing one. + +An example of output (e.g. `curl http://localhost:9464/metrics`): +```scala mdoc:passthrough +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import org.http4s._ +import org.http4s.syntax.literals._ +import org.typelevel.otel4s.sdk.autoconfigure.Config +import org.typelevel.otel4s.sdk.exporter.prometheus._ +import org.typelevel.otel4s.sdk.metrics.SdkMetrics + +val response = PrometheusMetricExporter.builder[IO].build.flatMap { exporter => + SdkMetrics + .autoConfigured[IO]( + _.withConfig(Config.ofProps(Map("otel.metrics.exporter" -> "none"))) + .addMeterProviderCustomizer((b, _) => b.registerMetricReader(exporter.metricReader)) + ) + .use { autoConfigured => + val sdk = autoConfigured + val routes = PrometheusHttpRoutes.routes[IO](exporter, PrometheusWriter.Config.default) + for { + meter <- sdk.meterProvider.meter("meter").get + counter <- meter.counter[Long]("counter").create + _ <- counter.inc() + response <- routes.orNotFound.run(Request(Method.GET, uri"/metrics")) + body <- response.bodyText.compile.foldMonoid + } yield body + } +}.unsafeRunSync() + +println("```yaml") +println(response) +println("```") +``` + +## Getting Started + +@:select(build-tool) + +@:choice(sbt) + +Add settings to the `build.sbt`: + +```scala +libraryDependencies ++= Seq( + "org.typelevel" %%% "otel4s-sdk" % "@VERSION@", // <1> + "org.typelevel" %%% "otel4s-sdk-exporter-prometheus" % "@VERSION@", // <2> +) +``` + +@:choice(scala-cli) + +Add directives to the `*.scala` file: + +```scala +//> using lib "org.typelevel::otel4s-sdk::@VERSION@" // <1> +//> using lib "org.typelevel::otel4s-sdk-exporter-prometheus::@VERSION@" // <2> +``` + +@:@ + +1. Add the `otel4s-sdk` library +2. Add the `otel4s-sdk-exporter-prometheus` library + +## Configuration + +The `OpenTelemetrySdk.autoConfigured(...)` and `SdkMetrics.autoConfigured(...)` rely on the environment variables +and system properties to configure the SDK. +Check out the [configuration details](configuration.md#prometheus-exporter). + +## Autoconfigured (built-in server) + +By default, Prometheus metrics exporter will launch its own HTTP server. +To make autoconfiguration work, we must configure the `otel.metrics.exporter` property: + +@:select(sdk-options-source) + +@:choice(sbt) + +Add settings to the `build.sbt`: + +```scala +javaOptions += "-Dotel.metrics.exporter=prometheus" +javaOptions += "-Dotel.traces.exporter=none" +envVars ++= Map("OTEL_METRICS_EXPORTER" -> "prometheus", "OTEL_TRACES_EXPORTER" -> "none") +``` + +@:choice(scala-cli) + +Add directives to the `*.scala` file: + +```scala +//> using javaOpt -Dotel.metrics.exporter=prometheus +//> using javaOpt -Dotel.traces.exporter=none +``` + +@:choice(shell) + +```shell +$ export OTEL_METRICS_EXPORTER=prometheus +$ export OTEL_TRACES_EXPORTER=none +``` +@:@ + +Then autoconfigure the SDK: + +@:select(sdk-entry-point) + +@:choice(sdk) + +`OpenTelemetrySdk.autoConfigured` configures both `MeterProvider` and `TracerProvider`: + +```scala mdoc:reset:silent +import cats.effect.{IO, IOApp} +import org.typelevel.otel4s.metrics.MeterProvider +import org.typelevel.otel4s.sdk.OpenTelemetrySdk +import org.typelevel.otel4s.sdk.exporter.prometheus.autoconfigure.PrometheusMetricExporterAutoConfigure +import org.typelevel.otel4s.trace.TracerProvider + +object TelemetryApp extends IOApp.Simple { + + def run: IO[Unit] = + OpenTelemetrySdk + .autoConfigured[IO]( + // register Prometheus exporter configurer + _.addMetricExporterConfigurer(PrometheusMetricExporterAutoConfigure[IO]) + ) + .use { autoConfigured => + val sdk = autoConfigured.sdk + + program(sdk.meterProvider, sdk.tracerProvider) >> IO.never + } + + def program( + meterProvider: MeterProvider[IO], + tracerProvider: TracerProvider[IO] + ): IO[Unit] = { + val _ = tracerProvider + for { + meter <- meterProvider.meter("meter").get + counter <- meter.counter[Long]("counter").create + _ <- counter.inc() + } yield () + } +} +``` + +@:choice(metrics) + +`SdkMetrics` configures only `MeterProvider`: + +```scala mdoc:reset:silent +import cats.effect.{IO, IOApp} +import org.typelevel.otel4s.metrics.MeterProvider +import org.typelevel.otel4s.sdk.exporter.prometheus.autoconfigure.PrometheusMetricExporterAutoConfigure +import org.typelevel.otel4s.sdk.metrics.SdkMetrics + +object TelemetryApp extends IOApp.Simple { + + def run: IO[Unit] = + SdkMetrics + .autoConfigured[IO]( + // register Prometheus exporters configurer + _.addExporterConfigurer(PrometheusMetricExporterAutoConfigure[IO]) + ) + .use { autoConfigured => + program(autoConfigured.meterProvider) + } + + def program( + meterProvider: MeterProvider[IO] + ): IO[Unit] = + for { + meter <- meterProvider.meter("meter").get + counter <- meter.counter[Long]("counter").create + _ <- counter.inc() + } yield () +} +``` + +@:@ + +The SDK will launch an HTTP server, and you can scrape metrics from the `http://localhost:9464/metrics` endpoint. + +## Manual (use Prometheus routes with existing server) + +If you already run an HTTP server, you can attach Prometheus routes to it. +For example, you can expose Prometheus metrics at `/prometheus/metrics` alongside your app routes. + +**Note**: since we configure the exporter manually, the exporter autoconfiguration must be disabled. + +@:select(sdk-options-source) + +@:choice(sbt) + +Add settings to the `build.sbt`: + +```scala +javaOptions += "-Dotel.metrics.exporter=none" +javaOptions += "-Dotel.traces.exporter=none" +envVars ++= Map("OTEL_METRICS_EXPORTER" -> "none", "OTEL_TRACES_EXPORTER" -> "none") +``` + +@:choice(scala-cli) + +Add directives to the `*.scala` file: + +```scala +//> using javaOpt -Dotel.metrics.exporter=none +//> using javaOpt -Dotel.traces.exporter=none +``` + +@:choice(shell) + +```shell +$ export OTEL_METRICS_EXPORTER=none +$ export OTEL_TRACES_EXPORTER=none +``` +@:@ + +Then autoconfigure the SDK and attach Prometheus routes to your HTTP server: + +@:select(sdk-entry-point) + +@:choice(sdk) + +`OpenTelemetrySdk.autoConfigured` configures both `MeterProvider` and `TracerProvider`: + +```scala mdoc:reset:silent +import cats.effect.{IO, IOApp} +import cats.syntax.semigroupk._ +import org.http4s._ +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.server.Router +import org.typelevel.otel4s.metrics.MeterProvider +import org.typelevel.otel4s.sdk.OpenTelemetrySdk +import org.typelevel.otel4s.sdk.exporter.prometheus._ +import org.typelevel.otel4s.trace.TracerProvider + +object TelemetryApp extends IOApp.Simple { + + def run: IO[Unit] = + PrometheusMetricExporter.builder[IO].build.flatMap { exporter => + OpenTelemetrySdk + .autoConfigured[IO]( + // disable exporter autoconfiguration + // can be skipped if you use system properties or env variables + _.addPropertiesCustomizer(_ => Map("otlp.metrics.exporter" -> "none")) + // register Prometheus exporter + .addMeterProviderCustomizer((b, _) => + b.registerMetricReader(exporter.metricReader) + ) + ) + .use { autoConfigured => + val sdk = autoConfigured.sdk + + val appRoutes: HttpRoutes[IO] = HttpRoutes.empty // your app routes + + val writerConfig = PrometheusWriter.Config.default + val prometheusRoutes = PrometheusHttpRoutes.routes[IO](exporter, writerConfig) + + val routes = appRoutes <+> Router("prometheus/metrics" -> prometheusRoutes) + + EmberServerBuilder.default[IO].withHttpApp(routes.orNotFound).build.use { _ => + program(sdk.meterProvider, sdk.tracerProvider) >> IO.never + } + } + } + + def program( + meterProvider: MeterProvider[IO], + tracerProvider: TracerProvider[IO] + ): IO[Unit] = { + val _ = tracerProvider + for { + meter <- meterProvider.meter("meter").get + counter <- meter.counter[Long]("counter").create + _ <- counter.inc() + } yield () + } +} +``` + +@:choice(metrics) + +`SdkMetrics` configures only `MeterProvider`: + +```scala mdoc:reset:silent +import cats.effect.{IO, IOApp} +import cats.syntax.semigroupk._ +import org.http4s._ +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.server.Router +import org.typelevel.otel4s.metrics.MeterProvider +import org.typelevel.otel4s.sdk.exporter.prometheus._ +import org.typelevel.otel4s.sdk.metrics.SdkMetrics + +object TelemetryApp extends IOApp.Simple { + + def run: IO[Unit] = + PrometheusMetricExporter.builder[IO].build.flatMap { exporter => + SdkMetrics + .autoConfigured[IO]( + // disable exporter autoconfiguration + // can be skipped if you use system properties or env variables + _.addPropertiesCustomizer(_ => Map("otlp.metrics.exporter" -> "none")) + // register Prometheus exporter + .addMeterProviderCustomizer((b, _) => + b.registerMetricReader(exporter.metricReader) + ) + ) + .use { autoConfigured => + val appRoutes: HttpRoutes[IO] = HttpRoutes.empty // your app routes + + val writerConfig = PrometheusWriter.Config.default + val prometheusRoutes = PrometheusHttpRoutes.routes[IO](exporter, writerConfig) + + val routes = appRoutes <+> Router("prometheus/metrics" -> prometheusRoutes) + + EmberServerBuilder.default[IO].withHttpApp(routes.orNotFound).build.use { _ => + program(autoConfigured.meterProvider) >> IO.never + } + } + } + + def program( + meterProvider: MeterProvider[IO] + ): IO[Unit] = + for { + meter <- meterProvider.meter("meter").get + counter <- meter.counter[Long]("counter").create + _ <- counter.inc() + } yield () +} +``` + +@:@ + +That way you attach Prometheus routes to the existing HTTP server, +and you can scrape metrics from the http://localhost:8080/prometheus/metrics endpoint. diff --git a/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusHttpRoutes.scala b/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusHttpRoutes.scala index 5b24fab5e..295bc48a9 100644 --- a/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusHttpRoutes.scala +++ b/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusHttpRoutes.scala @@ -21,6 +21,7 @@ import cats.effect.std.Console import cats.syntax.functor._ import fs2.compression.Compression import org.http4s.HttpRoutes +import org.http4s.MediaRange import org.http4s.Method.GET import org.http4s.Method.HEAD import org.http4s.Request @@ -42,7 +43,7 @@ object PrometheusHttpRoutes { val routes: HttpRoutes[F] = HttpRoutes.of { case Request(GET, _, _, headers, _, _) => - if (headers.get[Accept].forall(_.values.exists(_.mediaRange.isText))) { + if (headers.get[Accept].forall(_.values.exists(_.mediaRange.satisfiedBy(MediaRange.`text/*`)))) { for { metrics <- exporter.metricReader.collectAllMetrics } yield Response().withEntity(writer.write(metrics)).withHeaders("Content-Type" -> writer.contentType) diff --git a/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusMetricExporter.scala b/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusMetricExporter.scala index 07bfa8ea3..55e8b8d2a 100644 --- a/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusMetricExporter.scala +++ b/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusMetricExporter.scala @@ -208,7 +208,7 @@ object PrometheusMetricExporter { .build .evalTap { _ => val consoleMsg = - s"PrometheusMetricsExporter: launched Prometheus server at $host:$port, writer options: $writerConfig" + s"PrometheusMetricsExporter: launched Prometheus server at $host:$port/metrics, writer options: $writerConfig" Console[F].println(consoleMsg) } .as(exporter) diff --git a/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusWriter.scala b/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusWriter.scala index edb826b54..ddc88a8b5 100644 --- a/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusWriter.scala +++ b/sdk-exporter/prometheus/src/main/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusWriter.scala @@ -57,6 +57,9 @@ trait PrometheusWriter[F[_]] { def write[G[_]: Foldable](metrics: G[MetricData]): Stream[F, Byte] } +/** @see + * [[https://github.com/prometheus/docs/blob/main/content/docs/instrumenting/exposition_formats.md]] + */ object PrometheusWriter { private val TargetInfoName = "target_info" diff --git a/sdk-exporter/prometheus/src/test/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusHttpRoutesSuite.scala b/sdk-exporter/prometheus/src/test/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusHttpRoutesSuite.scala index 9fb36606a..f71ea760e 100644 --- a/sdk-exporter/prometheus/src/test/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusHttpRoutesSuite.scala +++ b/sdk-exporter/prometheus/src/test/scala/org/typelevel/otel4s/sdk/exporter/prometheus/PrometheusHttpRoutesSuite.scala @@ -20,6 +20,7 @@ import cats.effect.IO import munit.CatsEffectSuite import org.http4s.Headers import org.http4s.HttpRoutes +import org.http4s.MediaRange import org.http4s.MediaType import org.http4s.Method import org.http4s.Request @@ -64,6 +65,23 @@ class PrometheusHttpRoutesSuite extends CatsEffectSuite with SuiteRuntimePlatfor } } + test("respond with a text on GET request and wildcard accept") { + PrometheusMetricExporter.builder[IO].build.flatMap { exporter => + val routes: HttpRoutes[IO] = PrometheusHttpRoutes.routes(exporter, PrometheusWriter.Config.default) + + for { + response <- routes.orNotFound + .run( + Request(method = Method.HEAD, uri = uri"/", headers = Headers(Accept(MediaRange.`*/*`))) + ) + body <- response.body.compile.toList + } yield { + assertEquals(response.status, Status.Ok) + assertEquals(body.isEmpty, true) + } + } + } + test("respond with 406 error if client defines unacceptable content negotiation header") { PrometheusMetricExporter.builder[IO].build.flatMap { exporter => val routes: HttpRoutes[IO] = PrometheusHttpRoutes.routes(exporter, PrometheusWriter.Config.default) diff --git a/sdk-exporter/prometheus/src/test/scala/org/typelevel/otel4s/sdk/exporter/prometheus/autoconfigure/PrometheusMetricExporterAutoConfigureSuite.scala b/sdk-exporter/prometheus/src/test/scala/org/typelevel/otel4s/sdk/exporter/prometheus/autoconfigure/PrometheusMetricExporterAutoConfigureSuite.scala index edd66b382..effd2b7b2 100644 --- a/sdk-exporter/prometheus/src/test/scala/org/typelevel/otel4s/sdk/exporter/prometheus/autoconfigure/PrometheusMetricExporterAutoConfigureSuite.scala +++ b/sdk-exporter/prometheus/src/test/scala/org/typelevel/otel4s/sdk/exporter/prometheus/autoconfigure/PrometheusMetricExporterAutoConfigureSuite.scala @@ -34,7 +34,7 @@ class PrometheusMetricExporterAutoConfigureSuite extends CatsEffectSuite with Su List( Entry( Op.Println, - "PrometheusMetricsExporter: launched Prometheus server at localhost:9464, " + + "PrometheusMetricsExporter: launched Prometheus server at localhost:9464/metrics, " + "writer options: PrometheusWriter.Config{" + "unitSuffixDisabled=false, " + "typeSuffixDisabled=false, " + @@ -73,7 +73,7 @@ class PrometheusMetricExporterAutoConfigureSuite extends CatsEffectSuite with Su List( Entry( Op.Println, - "PrometheusMetricsExporter: launched Prometheus server at localhost:9464, " + + "PrometheusMetricsExporter: launched Prometheus server at localhost:9464/metrics, " + "writer options: PrometheusWriter.Config{" + "unitSuffixDisabled=false, " + "typeSuffixDisabled=false, " + @@ -111,7 +111,7 @@ class PrometheusMetricExporterAutoConfigureSuite extends CatsEffectSuite with Su List( Entry( Op.Println, - "PrometheusMetricsExporter: launched Prometheus server at 127.0.0.2:9465, " + + "PrometheusMetricsExporter: launched Prometheus server at 127.0.0.2:9465/metrics, " + "writer options: PrometheusWriter.Config{" + "unitSuffixDisabled=true, " + "typeSuffixDisabled=true, " + diff --git a/sdk/metrics/src/main/scala/org/typelevel/otel4s/sdk/metrics/SdkMeterProvider.scala b/sdk/metrics/src/main/scala/org/typelevel/otel4s/sdk/metrics/SdkMeterProvider.scala index 6269b1dae..3e099bff9 100644 --- a/sdk/metrics/src/main/scala/org/typelevel/otel4s/sdk/metrics/SdkMeterProvider.scala +++ b/sdk/metrics/src/main/scala/org/typelevel/otel4s/sdk/metrics/SdkMeterProvider.scala @@ -142,7 +142,7 @@ object SdkMeterProvider { * @param reader * the [[org.typelevel.otel4s.sdk.metrics.exporter.MetricReader MetricReader]] to register */ - private[sdk] def registerMetricReader(reader: MetricReader[F]): Builder[F] + def registerMetricReader(reader: MetricReader[F]): Builder[F] /** Registers a [[org.typelevel.otel4s.sdk.metrics.exporter.MetricProducer MetricProducer]]. *