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