diff --git a/docs/README.md b/docs/README.md
index d65be6d88822..22304eb1eb7c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -33,6 +33,11 @@ To build HTML versions of the manuals:
If you do not have Sphinx installed the manual will not be built.
The outputted files are written to the ``target/html`` directory.
+To view the docs, use the provided ``docs-server.sh`` script (requires Docker), then browse to
+``http://localhost:8080``:
+
+ $ ./docs/docs-server.sh
+
To build a PDF version:
$ mvn clean install -Pdocs,latex
diff --git a/docs/docs-server.sh b/docs/docs-server.sh
new file mode 100755
index 000000000000..aed64efdbc70
--- /dev/null
+++ b/docs/docs-server.sh
@@ -0,0 +1,8 @@
+#! /usr/bin/env bash
+
+dir="$(cd "$(dirname "$0")" || exit 1; pwd)"
+
+docker run --rm \
+ -p 8080:8080 \
+ -v "$dir/target/html:/usr/local/tomcat/webapps/ROOT" \
+ tomcat:9.0-jdk11
diff --git a/docs/user/appendix/metrics.rst b/docs/user/appendix/metrics.rst
index c20e6808abce..885e995cca41 100644
--- a/docs/user/appendix/metrics.rst
+++ b/docs/user/appendix/metrics.rst
@@ -6,8 +6,9 @@ GeoMesa Metrics
GeoMesa provides integration with the `DropWizard Metrics `__ library for real-time
reporting with the ``geomesa-metrics`` module.
-Reporters are available for `SLF4J `__, `CloudWatch `__,
-`Graphite `__, and `Ganglia `__.
+Reporters are available for `CloudWatch `__,
+`Prometheus `__, `Graphite `__,
+`Ganglia `__, and `SLF4J `__.
Configuration
-------------
@@ -86,6 +87,60 @@ Example configuration:
zero-values = false
}
+Prometheus Reporter
+-------------------
+
+The Prometheus reporter can be included by adding a dependency on
+``org.locationtech.geomesa:geomesa-metrics-prometheus``. The Prometheus reporter supports normal Prometheus scraping
+as well as the Prometheus Pushgateway. Note that the unit and interval configurations described above do not apply
+to Prometheus reporters.
+
+Prometheus Scraping
+^^^^^^^^^^^^^^^^^^^
+
+The standard Prometheus reporter will expose an HTTP endpoint to be scraped by Prometheus.
+
+====================== ===============================================================================================
+Configuration Property Description
+====================== ===============================================================================================
+``type`` Must be ``prometheus``
+``port`` The port used to expose metrics
+``suffix`` A suffix to append to all metric names
+====================== ===============================================================================================
+
+Example configuration:
+
+::
+
+ {
+ type = "prometheus"
+ port = "9090"
+ }
+
+Prometheus Pushgateway
+^^^^^^^^^^^^^^^^^^^^^^
+
+For short-lived jobs, metrics can be sent to a Prometheus Pushgateway instead of being exposed for scraping.
+
+====================== ===============================================================================================
+Configuration Property Description
+====================== ===============================================================================================
+``type`` Must be ``prometheus-pushgateway``
+``gateway`` The Pushgateway host
+``job-name`` The name of the batch job being run
+``suffix`` A suffix to append to all metric names
+====================== ===============================================================================================
+
+Example configuration:
+
+::
+
+ {
+ type = "prometheus-pushgateway"
+ gateway = "http://pushgateway:8080/"
+ job-name = "my-job"
+ }
+
Ganglia Reporter
----------------
diff --git a/geomesa-metrics/geomesa-metrics-prometheus/pom.xml b/geomesa-metrics/geomesa-metrics-prometheus/pom.xml
new file mode 100644
index 000000000000..b4df074bfe31
--- /dev/null
+++ b/geomesa-metrics/geomesa-metrics-prometheus/pom.xml
@@ -0,0 +1,52 @@
+
+
+
+
+ org.locationtech.geomesa
+ geomesa-metrics_2.12
+ 4.1.0-SNAPSHOT
+
+ 4.0.0
+
+ geomesa-metrics-prometheus_2.12
+ GeoMesa Metrics Prometheus Reporter
+
+
+
+ org.locationtech.geomesa
+ geomesa-metrics-core_${scala.binary.version}
+
+
+ io.prometheus
+ simpleclient_dropwizard
+ 0.16.0
+
+
+ io.prometheus
+ simpleclient_httpserver
+ 0.16.0
+
+
+ io.prometheus
+ simpleclient_pushgateway
+ 0.16.0
+
+
+
+
+ org.specs2
+ specs2-core_${scala.binary.version}
+
+
+ org.specs2
+ specs2-junit_${scala.binary.version}
+
+
+ org.mortbay.jetty
+ jetty
+ 6.1.26
+ test
+
+
+
+
diff --git a/geomesa-metrics/geomesa-metrics-prometheus/src/main/resources/META-INF/services/org.locationtech.geomesa.metrics.core.ReporterFactory b/geomesa-metrics/geomesa-metrics-prometheus/src/main/resources/META-INF/services/org.locationtech.geomesa.metrics.core.ReporterFactory
new file mode 100644
index 000000000000..162dd41b1a3b
--- /dev/null
+++ b/geomesa-metrics/geomesa-metrics-prometheus/src/main/resources/META-INF/services/org.locationtech.geomesa.metrics.core.ReporterFactory
@@ -0,0 +1 @@
+org.locationtech.geomesa.metrics.prometheus.PrometheusReporterFactory
diff --git a/geomesa-metrics/geomesa-metrics-prometheus/src/main/resources/prometheus-defaults.conf b/geomesa-metrics/geomesa-metrics-prometheus/src/main/resources/prometheus-defaults.conf
new file mode 100644
index 000000000000..3792a7b6aed3
--- /dev/null
+++ b/geomesa-metrics/geomesa-metrics-prometheus/src/main/resources/prometheus-defaults.conf
@@ -0,0 +1 @@
+port = 9090
diff --git a/geomesa-metrics/geomesa-metrics-prometheus/src/main/scala/org/locationtech/geomesa/metrics/prometheus/PrometheusReporterFactory.scala b/geomesa-metrics/geomesa-metrics-prometheus/src/main/scala/org/locationtech/geomesa/metrics/prometheus/PrometheusReporterFactory.scala
new file mode 100644
index 000000000000..7dfb431fd36f
--- /dev/null
+++ b/geomesa-metrics/geomesa-metrics-prometheus/src/main/scala/org/locationtech/geomesa/metrics/prometheus/PrometheusReporterFactory.scala
@@ -0,0 +1,154 @@
+/***********************************************************************
+ * Copyright (c) 2013-2023 Commonwealth Computer Research, Inc.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Apache License, Version 2.0
+ * which accompanies this distribution and is available at
+ * http://www.opensource.org/licenses/apache2.0.php.
+ ***********************************************************************/
+
+package org.locationtech.geomesa.metrics.prometheus
+
+import com.codahale.metrics._
+import com.typesafe.config.{Config, ConfigFactory}
+import io.prometheus.client.Collector.MetricFamilySamples
+import io.prometheus.client.CollectorRegistry
+import io.prometheus.client.dropwizard.DropwizardExports
+import io.prometheus.client.dropwizard.samplebuilder.{DefaultSampleBuilder, SampleBuilder}
+import io.prometheus.client.exporter.{HTTPServer, PushGateway}
+import org.locationtech.geomesa.metrics.core.ReporterFactory
+import org.locationtech.geomesa.utils.io.CloseWithLogging
+import pureconfig.{ConfigReader, ConfigSource}
+
+import java.net.URL
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+class PrometheusReporterFactory extends ReporterFactory {
+
+ import PrometheusReporterFactory._
+
+ override def apply(
+ conf: Config,
+ registry: MetricRegistry,
+ rates: TimeUnit,
+ durations: TimeUnit): Option[ScheduledReporter] = {
+ val typ = if (conf.hasPath("type")) { Option(conf.getString("type")).map(_.toLowerCase(Locale.US)) } else { None }
+ typ match {
+ case Some("prometheus") =>
+ val config = ConfigSource.fromConfig(conf.withFallback(Defaults)).loadOrThrow[PrometheusConfig]
+ Some(new PrometheusReporter(registry, config.suffix, config.port))
+
+ case Some("prometheus-pushgateway") =>
+ val config = ConfigSource.fromConfig(conf.withFallback(Defaults)).loadOrThrow[PrometheusPushgatewayConfig]
+ Some(new PushgatewayReporter(registry, config.suffix, config.gateway, config.jobName))
+
+ case _ =>
+ None
+ }
+ }
+}
+
+object PrometheusReporterFactory {
+
+ import pureconfig.generic.semiauto._
+
+ private val Defaults = ConfigFactory.parseResourcesAnySyntax("prometheus-defaults").resolve()
+
+ case class PrometheusConfig(port: Int, suffix: Option[String])
+ case class PrometheusPushgatewayConfig(gateway: URL, jobName: String, suffix: Option[String])
+
+ implicit val PrometheusConfigReader: ConfigReader[PrometheusConfig] = deriveReader[PrometheusConfig]
+ implicit val PrometheusPushgatewayConfigReader: ConfigReader[PrometheusPushgatewayConfig] =
+ deriveReader[PrometheusPushgatewayConfig]
+
+ /**
+ * Prometheus reporter
+ *
+ * @param registry registry
+ * @param suffix metrics suffix
+ * @param port http server port, or zero to use an ephemeral open port
+ */
+ class PrometheusReporter(registry: MetricRegistry, suffix: Option[String], port: Int)
+ extends BasePrometheusReporter(registry, CollectorRegistry.defaultRegistry, suffix) {
+
+ private val server = new HTTPServer.Builder().withPort(port).build()
+
+ def getPort: Int = server.getPort
+
+ override def close(): Unit = {
+ CloseWithLogging(server)
+ super.close()
+ }
+ }
+
+ /**
+ * Pushgateway reporter
+ *
+ * Note: we use a new collector registry, as generally with pushgateway you don't want to expose things
+ * from the default registry like memory, etc
+ *
+ * @param registry registry
+ * @param suffix metrics suffix
+ * @param gateway pushgateway url
+ * @param jobName pushgateway job name
+ */
+ class PushgatewayReporter(registry: MetricRegistry, suffix: Option[String], gateway: URL, jobName: String)
+ extends BasePrometheusReporter(registry, new CollectorRegistry(), suffix) {
+ override def close(): Unit = {
+ try { new PushGateway(gateway).pushAdd(collectorRegistry, jobName) } finally {
+ super.close()
+ }
+ }
+ }
+
+ /**
+ * Placeholder reporter that doesn't report any metrics, but handles the lifecycle of the prometheus
+ * pusher or server
+ *
+ * @param registry registry
+ * @param collectorRegistry prometheus registry
+ * @param suffix metrics suffix
+ */
+ class BasePrometheusReporter(
+ registry: MetricRegistry,
+ protected val collectorRegistry: CollectorRegistry,
+ suffix: Option[String]
+ ) extends ScheduledReporter(registry, "prometheus", MetricFilter.ALL, TimeUnit.MILLISECONDS, TimeUnit.MILLISECONDS) {
+
+ private val sampler = suffix match {
+ case None => new DefaultSampleBuilder()
+ case Some(s) => new SuffixSampleBuilder(s)
+ }
+ new DropwizardExports(registry, sampler).register(collectorRegistry)
+
+ // since prometheus scrapes metrics, we don't have to report them here
+
+ override def start(initialDelay: Long, period: Long, unit: TimeUnit): Unit = {}
+
+ override def report(
+ gauges: java.util.SortedMap[String, Gauge[_]],
+ counters: java.util.SortedMap[String, Counter],
+ histograms: java.util.SortedMap[String, Histogram],
+ meters: java.util.SortedMap[String, Meter],
+ timers: java.util.SortedMap[String, Timer]): Unit = {}
+ }
+
+ /**
+ * Adds a suffix to each metric
+ *
+ * @param suffix suffix
+ */
+ class SuffixSampleBuilder(suffix: String) extends SampleBuilder {
+
+ private val builder = new DefaultSampleBuilder()
+
+ override def createSample(
+ dropwizardName: String,
+ nameSuffix: String,
+ additionalLabelNames: java.util.List[String],
+ additionalLabelValues: java.util.List[String],
+ value: Double): MetricFamilySamples.Sample = {
+ builder.createSample(dropwizardName, suffix, additionalLabelNames, additionalLabelValues, value)
+ }
+ }
+}
diff --git a/geomesa-metrics/geomesa-metrics-prometheus/src/test/scala/org/locationtech/geomesa/metrics/prometheus/PrometheusReporterTest.scala b/geomesa-metrics/geomesa-metrics-prometheus/src/test/scala/org/locationtech/geomesa/metrics/prometheus/PrometheusReporterTest.scala
new file mode 100644
index 000000000000..2e09a386103a
--- /dev/null
+++ b/geomesa-metrics/geomesa-metrics-prometheus/src/test/scala/org/locationtech/geomesa/metrics/prometheus/PrometheusReporterTest.scala
@@ -0,0 +1,149 @@
+/***********************************************************************
+ * Copyright (c) 2013-2023 Commonwealth Computer Research, Inc.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Apache License, Version 2.0
+ * which accompanies this distribution and is available at
+ * http://www.opensource.org/licenses/apache2.0.php.
+ ***********************************************************************/
+
+package org.locationtech.geomesa.metrics.prometheus
+
+import com.codahale.metrics.MetricRegistry
+import com.typesafe.config.ConfigFactory
+import org.apache.commons.io.IOUtils
+import org.junit.runner.RunWith
+import org.locationtech.geomesa.metrics.core.ReporterFactory
+import org.locationtech.geomesa.metrics.prometheus.PrometheusReporterFactory.{PrometheusReporter, PushgatewayReporter}
+import org.locationtech.geomesa.utils.io.WithClose
+import org.mortbay.jetty.{Request, Server}
+import org.mortbay.jetty.handler.AbstractHandler
+import org.specs2.mutable.Specification
+import org.specs2.runner.JUnitRunner
+
+import java.io.{BufferedReader, InputStreamReader}
+import java.net.URL
+import java.nio.charset.StandardCharsets
+import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
+import scala.collection.mutable.ArrayBuffer
+
+@RunWith(classOf[JUnitRunner])
+class PrometheusReporterTest extends Specification {
+
+ "Prometheus reporter" should {
+ "expose metrics over http" in {
+ val conf = ConfigFactory.parseString("{ type = prometheus, port = 0 }") // use zero for random port
+ val registry = new MetricRegistry()
+ val reporter = ReporterFactory(conf, registry)
+ try {
+ reporter must beAnInstanceOf[PrometheusReporter]
+ val port = reporter.asInstanceOf[PrometheusReporter].getPort
+
+ registry.counter("foo").inc(10)
+
+ val metrics = ArrayBuffer.empty[String]
+ val url = new URL(s"http://localhost:$port/metrics")
+ WithClose(new BufferedReader(new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) { is =>
+ var line = is.readLine()
+ while (line != null) {
+ metrics += line
+ line = is.readLine()
+ }
+ }
+
+ metrics must contain("foo 10.0")
+ } finally {
+ reporter.close()
+ }
+ }
+
+ "expose metrics over http with custom suffix" in {
+ val conf = ConfigFactory.parseString("{ type = prometheus, port = 0, suffix = bar }") // use zero for random port
+ val registry = new MetricRegistry()
+ val reporter = ReporterFactory(conf, registry)
+ try {
+ reporter must beAnInstanceOf[PrometheusReporter]
+ val port = reporter.asInstanceOf[PrometheusReporter].getPort
+
+ registry.counter("foo").inc(10)
+
+ val metrics = ArrayBuffer.empty[String]
+ val url = new URL(s"http://localhost:$port/metrics")
+ WithClose(new BufferedReader(new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) { is =>
+ var line = is.readLine()
+ while (line != null) {
+ metrics += line
+ line = is.readLine()
+ }
+ }
+
+ metrics must contain("foobar 10.0")
+ } finally {
+ reporter.close()
+ }
+ }
+
+ "push metrics to a gateway" in {
+ val handler = new PgHandler()
+ val jetty = new Server(0)
+ jetty.setHandler(handler)
+ try {
+ jetty.start()
+ val port = jetty.getConnectors()(0).getLocalPort
+ val conf =
+ ConfigFactory.parseString(
+ s"""{ type = prometheus-pushgateway, gateway = "http://localhost:$port", job-name = job1 }""")
+ val registry = new MetricRegistry()
+ val reporter = ReporterFactory(conf, registry)
+ try {
+ reporter must beAnInstanceOf[PushgatewayReporter]
+ registry.counter("foo").inc(10)
+ } finally {
+ reporter.close()
+ }
+ handler.requests.keys must contain("/metrics/job/job1")
+ val metrics = handler.requests("/metrics/job/job1")
+ metrics must contain("foo 10.0")
+ } finally {
+ jetty.stop()
+ }
+ }
+
+ "push metrics to a gateway with suffix" in {
+ val handler = new PgHandler()
+ val jetty = new Server(0)
+ jetty.setHandler(handler)
+ try {
+ jetty.start()
+ val port = jetty.getConnectors()(0).getLocalPort
+ val conf =
+ ConfigFactory.parseString(
+ s"""{ type = prometheus-pushgateway, gateway = "http://localhost:$port", job-name = job1, suffix = bar }""")
+ val registry = new MetricRegistry()
+ val reporter = ReporterFactory(conf, registry)
+ try {
+ reporter must beAnInstanceOf[PushgatewayReporter]
+ registry.counter("foo").inc(10)
+ } finally {
+ reporter.close()
+ }
+ handler.requests.keys must contain("/metrics/job/job1")
+ val metrics = handler.requests("/metrics/job/job1")
+ metrics must contain("foobar 10.0")
+ } finally {
+ jetty.stop()
+ }
+ }
+ }
+
+ class PgHandler extends AbstractHandler {
+ val requests: scala.collection.mutable.Map[String, String] = scala.collection.mutable.Map.empty[String, String]
+ override def handle(s: String, req: HttpServletRequest, resp: HttpServletResponse, i: Int): Unit = {
+ val is = req.getInputStream
+ try { requests += req.getPathInfo -> IOUtils.toString(is, StandardCharsets.UTF_8) } finally {
+ is.close()
+ }
+ resp.setStatus(HttpServletResponse.SC_OK)
+ req.asInstanceOf[Request].setHandled(true)
+ }
+ }
+}
diff --git a/geomesa-metrics/pom.xml b/geomesa-metrics/pom.xml
index 7780e1327edc..0ff15f656ddc 100644
--- a/geomesa-metrics/pom.xml
+++ b/geomesa-metrics/pom.xml
@@ -17,6 +17,7 @@
geomesa-metrics-cloudwatch
geomesa-metrics-ganglia
geomesa-metrics-graphite
+ geomesa-metrics-prometheus