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 -H "Accept:text/plain" http://localhost:9464/metrics):

# TYPE counter_total counter
counter_total{otel_scope_name="meter"} 1
# HELP target_info Target metadata
# TYPE target_info gauge
target_info{host_arch="amd64",host_name="fv-az532-915",os_description="Linux 6.5.0-1025-azure",os_type="linux",process_command_line="/usr/lib/jvm/temurin-11-jdk-amd64/bin/java -Xms1G -Xmx4G -XX:+UseG1GC -Dsbt.script=/opt/hostedtoolcache/sbt/1.10.5/sbt/bin/sbt -Dscala.ext.dirs=/home/runner/.sbt/1.0/java9-rt-ext-eclipse_adoptium_11_0_25 /opt/hostedtoolcache/sbt/1.10.5/sbt/bin/sbt-launch.jar docs/tlSite",process_executable_path="/usr/lib/jvm/temurin-11-jdk-amd64/bin/java",process_pid="1782",process_runtime_description="Eclipse Adoptium OpenJDK 64-Bit Server VM 11.0.25+9",process_runtime_name="OpenJDK Runtime Environment",process_runtime_version="11.0.25+9",service_name="unknown_service:scala",telemetry_sdk_language="scala",telemetry_sdk_name="otel4s",telemetry_sdk_version="0.11.1-24-e2c4ee7-SNAPSHOT"} 1

Getting Started

Add settings to the build.sbt:

libraryDependencies ++= Seq(
  "org.typelevel" %%% "otel4s-sdk" % "0.11.1", // <1>
  "org.typelevel" %%% "otel4s-sdk-exporter-prometheus" % "0.11.1", // <2>
)

Add directives to the *.scala file:

//> using dep "org.typelevel::otel4s-sdk::0.11.1" // <1>
//> using dep "org.typelevel::otel4s-sdk-exporter-prometheus::0.11.1" // <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.

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:

Add settings to the build.sbt:

javaOptions += "-Dotel.metrics.exporter=prometheus"
javaOptions += "-Dotel.traces.exporter=none"
envVars ++= Map("OTEL_METRICS_EXPORTER" -> "prometheus", "OTEL_TRACES_EXPORTER" -> "none")

Add directives to the *.scala file:

//> using javaOpt -Dotel.metrics.exporter=prometheus
//> using javaOpt -Dotel.traces.exporter=none
$ export OTEL_METRICS_EXPORTER=prometheus
$ export OTEL_TRACES_EXPORTER=none

Then autoconfigure the SDK:

OpenTelemetrySdk.autoConfigured configures both MeterProvider and TracerProvider:

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 ()
  }
}

SdkMetrics configures only MeterProvider:

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.

Add settings to the build.sbt:

javaOptions += "-Dotel.metrics.exporter=none"
javaOptions += "-Dotel.traces.exporter=none"
envVars ++= Map("OTEL_METRICS_EXPORTER" -> "none", "OTEL_TRACES_EXPORTER" -> "none")

Add directives to the *.scala file:

//> using javaOpt -Dotel.metrics.exporter=none
//> using javaOpt -Dotel.traces.exporter=none
$ export OTEL_METRICS_EXPORTER=none
$ export OTEL_TRACES_EXPORTER=none

Then autoconfigure the SDK and attach Prometheus routes to your HTTP server:

OpenTelemetrySdk.autoConfigured configures both MeterProvider and TracerProvider:

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 ()
  }
}

SdkMetrics configures only MeterProvider:

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.