From 1fe4b6c855595c819940a50c13588046b22d2481 Mon Sep 17 00:00:00 2001 From: Stijn De Haes Date: Sat, 24 Jun 2017 12:32:07 +0200 Subject: [PATCH] Initial commit, three unit tested filters, one controller --- .gitignore | 8 +++ README.md | 56 +++++++++++++++++++ app/play/prometheus/PrometheusModule.scala | 11 ++++ .../controllers/PrometheusController.scala | 25 +++++++++ .../prometheus/filters/LatencyFilter.scala | 28 ++++++++++ .../RouteActionMethodLatencyFilter.scala | 29 ++++++++++ .../filters/StatusCounterFilter.scala | 29 ++++++++++ app/play/prometheus/utils/WriterAdapter.scala | 14 +++++ build.sbt | 18 ++++++ conf/reference.conf | 1 + project/build.properties | 1 + project/plugins.sbt | 2 + .../PrometheusControllerSpec.scala | 33 +++++++++++ .../filters/LatencyFilterSpec.scala | 43 ++++++++++++++ .../RouteActionMethodLatencyFilterSpec.scala | 47 ++++++++++++++++ .../filters/StatusCounterFilterSpec.scala | 42 ++++++++++++++ 16 files changed, 387 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/play/prometheus/PrometheusModule.scala create mode 100644 app/play/prometheus/controllers/PrometheusController.scala create mode 100644 app/play/prometheus/filters/LatencyFilter.scala create mode 100644 app/play/prometheus/filters/RouteActionMethodLatencyFilter.scala create mode 100644 app/play/prometheus/filters/StatusCounterFilter.scala create mode 100644 app/play/prometheus/utils/WriterAdapter.scala create mode 100644 build.sbt create mode 100644 conf/reference.conf create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 test/play/prometheus/controllers/PrometheusControllerSpec.scala create mode 100644 test/play/prometheus/filters/LatencyFilterSpec.scala create mode 100644 test/play/prometheus/filters/RouteActionMethodLatencyFilterSpec.scala create mode 100644 test/play/prometheus/filters/StatusCounterFilterSpec.scala diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb372fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +logs +target +/.idea +/.idea_modules +/.classpath +/.project +/.settings +/RUNNING_PID diff --git a/README.md b/README.md new file mode 100644 index 0000000..10e8fc5 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +#Play prometheus play.prometheus.filters + +This play library provides three types of filters that collect prometheus metrics. + +##The filters + +####Request counter +This filter counts all the requests in your application and adds a label for the status + +####Latency filter +This filter collects the latency of all requests + +####Route Action Method Latency Filter +This filter collects the latency for all requests and adds a label call RouteActionMethod. +This action method is the method name of the method you provided your routes file. +This filter makes it possible to measure the latency for all your routes. + +Example: + +``` +GET /metrics play.prometheus.controllers.PrometheusController.getMetrics +``` + +The RouteActionMethod for the above example would be getMetrics + +##How to enable the filters +See the [documentation of play](https://www.playframework.com/documentation/2.5.x/ScalaHttpFilters#Using-filters) + +You should make a filters class: + +```scala +import javax.inject.Inject +import play.api.http.DefaultHttpFilters +import play.prometheus.filters.LatencyFilter +import play.prometheus.filters.StatusCounterFilter + +class MyFilters @Inject() ( + latencyFilter: LatencyFilter, + statusCounterFilter: StatusCounterFilter +) extends DefaultHttpFilters(latencyFilter, statusCounterFilter) +``` + +And the enable this filter in the application.conf + +```$xslt +play.http.filters=com.example.MyFilters +``` + +##Prometheus controller +The project also provides a prometheus controller with a get metric method. If you add the following to your routes file: + +``` +GET /metrics play.prometheus.controllers.PrometheusController.getMetrics +``` + +You should be able to immediately get the metrics \ No newline at end of file diff --git a/app/play/prometheus/PrometheusModule.scala b/app/play/prometheus/PrometheusModule.scala new file mode 100644 index 0000000..4b1cdf8 --- /dev/null +++ b/app/play/prometheus/PrometheusModule.scala @@ -0,0 +1,11 @@ +package play.prometheus + +import com.google.inject.AbstractModule +import io.prometheus.client.CollectorRegistry + +class PrometheusModule extends AbstractModule { + + override def configure(): Unit = { + bind(classOf[CollectorRegistry]).toInstance(CollectorRegistry.defaultRegistry) + } +} diff --git a/app/play/prometheus/controllers/PrometheusController.scala b/app/play/prometheus/controllers/PrometheusController.scala new file mode 100644 index 0000000..4a9a384 --- /dev/null +++ b/app/play/prometheus/controllers/PrometheusController.scala @@ -0,0 +1,25 @@ +package play.prometheus.controllers + +import akka.util.ByteString +import com.google.inject.Inject +import io.prometheus.client.CollectorRegistry +import io.prometheus.client.exporter.common.TextFormat +import play.api.http.HttpEntity +import play.api.mvc._ +import play.prometheus.utils.WriterAdapter + +class PrometheusController @Inject()(registry: CollectorRegistry) extends Controller { + + def getMetrics = Action { + val samples = new StringBuilder() + val writer = new WriterAdapter(samples) + TextFormat.write004(writer, registry.metricFamilySamples()) + writer.close() + + Result( + header = ResponseHeader(200, Map.empty), + body = HttpEntity.Strict(ByteString(samples.toString), Some(TextFormat.CONTENT_TYPE_004)) + ) + } + +} \ No newline at end of file diff --git a/app/play/prometheus/filters/LatencyFilter.scala b/app/play/prometheus/filters/LatencyFilter.scala new file mode 100644 index 0000000..7a94fe9 --- /dev/null +++ b/app/play/prometheus/filters/LatencyFilter.scala @@ -0,0 +1,28 @@ +package play.prometheus.filters + +import akka.stream.Materializer +import com.google.inject.{Inject, Singleton} +import io.prometheus.client.{CollectorRegistry, Histogram} +import play.api.mvc.{Filter, RequestHeader, Result} + +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class LatencyFilter @Inject()(registry: CollectorRegistry) (implicit val mat: Materializer, ec: ExecutionContext) extends Filter { + + private[filters] val requestLatency = Histogram.build + .name("requests_latency_seconds") + .help("Request latency in seconds.") + .register(registry) + + def apply(nextFilter: RequestHeader => Future[Result]) + (requestHeader: RequestHeader): Future[Result] = { + + val requestTimer = requestLatency.startTimer + nextFilter(requestHeader).map { result => + requestTimer.observeDuration() + result + } + } + +} \ No newline at end of file diff --git a/app/play/prometheus/filters/RouteActionMethodLatencyFilter.scala b/app/play/prometheus/filters/RouteActionMethodLatencyFilter.scala new file mode 100644 index 0000000..92caaff --- /dev/null +++ b/app/play/prometheus/filters/RouteActionMethodLatencyFilter.scala @@ -0,0 +1,29 @@ +package play.prometheus.filters + +import akka.stream.Materializer +import com.google.inject.{Inject, Singleton} +import io.prometheus.client.{CollectorRegistry, Histogram} +import play.api.mvc.{Filter, RequestHeader, Result} +import play.api.routing.Router.Tags + +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class RouteActionMethodLatencyFilter @Inject()(registry: CollectorRegistry) (implicit val mat: Materializer, ec: ExecutionContext) extends Filter { + + private[filters] val requestLatency = Histogram.build + .name("requests_latency_seconds") + .help("Request latency in seconds.") + .labelNames("RouteActionMethod") + .register(registry) + + def apply(nextFilter: RequestHeader => Future[Result]) + (requestHeader: RequestHeader): Future[Result] = { + val requestTimer = requestLatency.labels(requestHeader.tags(Tags.RouteActionMethod)).startTimer + nextFilter(requestHeader).map { result => + requestTimer.observeDuration() + result + } + } + +} \ No newline at end of file diff --git a/app/play/prometheus/filters/StatusCounterFilter.scala b/app/play/prometheus/filters/StatusCounterFilter.scala new file mode 100644 index 0000000..5cfb730 --- /dev/null +++ b/app/play/prometheus/filters/StatusCounterFilter.scala @@ -0,0 +1,29 @@ +package play.prometheus.filters + +import javax.inject.Inject + +import akka.stream.Materializer +import com.google.inject.Singleton +import io.prometheus.client.{CollectorRegistry, Counter} +import play.api.mvc.{Filter, RequestHeader, Result} + +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class StatusCounterFilter @Inject()(registry: CollectorRegistry) (implicit val mat: Materializer, ec: ExecutionContext) extends Filter { + + private[filters] val requestCounter = Counter.build() + .name("http_requests_total") + .help("Total amount of requests") + .labelNames("status") + .register(registry) + + def apply(nextFilter: RequestHeader => Future[Result]) + (requestHeader: RequestHeader): Future[Result] = { + + nextFilter(requestHeader).map { result => + requestCounter.labels(result.header.status.toString).inc() + result + } + } +} \ No newline at end of file diff --git a/app/play/prometheus/utils/WriterAdapter.scala b/app/play/prometheus/utils/WriterAdapter.scala new file mode 100644 index 0000000..7f9ac3c --- /dev/null +++ b/app/play/prometheus/utils/WriterAdapter.scala @@ -0,0 +1,14 @@ +package play.prometheus.utils + +import java.io.Writer + +class WriterAdapter(buffer: StringBuilder) extends Writer { + + override def write(charArray: Array[Char], offset: Int, length: Int): Unit = { + buffer ++= new String(new String(charArray, offset, length).getBytes("UTF-8"), "UTF-8") + } + + override def flush(): Unit = {} + + override def close(): Unit = {} +} diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..f64800f --- /dev/null +++ b/build.sbt @@ -0,0 +1,18 @@ +name := """play-prometheus-play.prometheus.filters""" + +version := "1.0-SNAPSHOT" + +lazy val root = (project in file(".")) + .enablePlugins(PlayScala) + +scalaVersion := "2.11.11" + +libraryDependencies ++= Seq( + "io.prometheus" % "simpleclient" % "0.0.23", + "io.prometheus" % "simpleclient_servlet" % "0.0.23" +) + +libraryDependencies ++= Seq( + "org.scalatestplus.play" %% "scalatestplus-play" % "2.0.0" % Test, + "org.mockito" % "mockito-core" % "2.7.22" % Test +) \ No newline at end of file diff --git a/conf/reference.conf b/conf/reference.conf new file mode 100644 index 0000000..88bd241 --- /dev/null +++ b/conf/reference.conf @@ -0,0 +1 @@ +play.modules.enabled += play.prometheus.PrometheusModule \ No newline at end of file diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..64317fd --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.15 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..4343084 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,2 @@ +// The Play plugin +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.15") diff --git a/test/play/prometheus/controllers/PrometheusControllerSpec.scala b/test/play/prometheus/controllers/PrometheusControllerSpec.scala new file mode 100644 index 0000000..0a05de1 --- /dev/null +++ b/test/play/prometheus/controllers/PrometheusControllerSpec.scala @@ -0,0 +1,33 @@ +package play.prometheus.controllers + +import java.util.Collections + +import io.prometheus.client.Collector.MetricFamilySamples +import io.prometheus.client.{Collector, CollectorRegistry} +import org.mockito.Mockito._ +import org.scalatest.mockito.MockitoSugar +import org.scalatestplus.play.PlaySpec +import play.api.mvc.Results +import play.api.test.FakeRequest +import play.api.test.Helpers._ + + +class PrometheusControllerSpec extends PlaySpec with Results with MockitoSugar { + + "Get metrics method" should { + "Return the prometheus metrics" in { + val collectorRegistry = mock[CollectorRegistry] + val metricsFamilySample = new MetricFamilySamples("test", Collector.Type.COUNTER, "help", Collections.emptyList()) + when(collectorRegistry.metricFamilySamples()).thenReturn(new java.util.Vector(Collections.singleton(metricsFamilySample)).elements) + + val client = new PrometheusController(collectorRegistry) + + val request = FakeRequest(GET, "/metrics") + + val result = client.getMetrics.apply(request) + status(result) mustBe OK + contentAsString(result) mustBe "# HELP test help\n# TYPE test counter\n" + } + } + +} diff --git a/test/play/prometheus/filters/LatencyFilterSpec.scala b/test/play/prometheus/filters/LatencyFilterSpec.scala new file mode 100644 index 0000000..5171215 --- /dev/null +++ b/test/play/prometheus/filters/LatencyFilterSpec.scala @@ -0,0 +1,43 @@ +package play.prometheus.filters + +import akka.stream.Materializer +import io.prometheus.client.CollectorRegistry +import org.mockito.ArgumentMatchers._ +import org.mockito.Mockito._ +import org.scalatest.mockito.MockitoSugar +import org.scalatest.{MustMatchers, WordSpec} +import org.scalatestplus.play.guice.GuiceOneAppPerSuite +import play.api.libs.concurrent.Execution.Implicits._ +import play.api.mvc._ +import play.api.test.{DefaultAwaitTimeout, FakeRequest, FutureAwaits} + +class LatencyFilterSpec extends WordSpec with MustMatchers with MockitoSugar with Results with DefaultAwaitTimeout with FutureAwaits with GuiceOneAppPerSuite { + + "Filter constructor" should { + "Add a histogram to the prometheus registry" in { + val collectorRegistry = mock[CollectorRegistry] + new LatencyFilter(collectorRegistry)(mock[Materializer], defaultContext) + verify(collectorRegistry).register(any()) + } + } + + "Apply method" should { + "Measure the latency" in { + implicit val mat = app.materializer + val filter = new LatencyFilter(mock[CollectorRegistry]) + val rh = FakeRequest() + val action = Action(Ok("success")) + + await(filter(action)(rh).run()) + + val metrics = filter.requestLatency.collect() + metrics must have size 1 + val samples = metrics.get(0).samples + //this is the count sample + val countSample = samples.get(samples.size() - 2) + countSample.value mustBe 1.0 + countSample.labelValues must have size 0 + } + } + +} diff --git a/test/play/prometheus/filters/RouteActionMethodLatencyFilterSpec.scala b/test/play/prometheus/filters/RouteActionMethodLatencyFilterSpec.scala new file mode 100644 index 0000000..67da3ff --- /dev/null +++ b/test/play/prometheus/filters/RouteActionMethodLatencyFilterSpec.scala @@ -0,0 +1,47 @@ +package play.prometheus.filters + +import akka.stream.Materializer +import io.prometheus.client.CollectorRegistry +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.verify +import org.scalatest.mockito.MockitoSugar +import org.scalatest.{MustMatchers, WordSpec} +import org.scalatestplus.play.guice.GuiceOneAppPerSuite +import play.api.libs.concurrent.Execution.Implicits.defaultContext +import play.api.mvc.Action +import play.api.test.{DefaultAwaitTimeout, FakeRequest, FutureAwaits} +import play.api.mvc._ +import play.api.routing.Router.Tags + +class RouteActionMethodLatencyFilterSpec extends WordSpec with MustMatchers with MockitoSugar with Results with DefaultAwaitTimeout with FutureAwaits with GuiceOneAppPerSuite { + + private implicit val mat = app.materializer + + "Filter constructor" should { + "Add a histogram to the prometheus registry" in { + val collectorRegistry = mock[CollectorRegistry] + new RouteActionMethodLatencyFilter(collectorRegistry) + verify(collectorRegistry).register(any()) + } + } + + "Apply method" should { + "Measure the latency" in { + val filter = new RouteActionMethodLatencyFilter(mock[CollectorRegistry]) + val rh = FakeRequest().withTag(Tags.RouteActionMethod, "test") + val action = Action(Ok("success")) + + await(filter(action)(rh).run()) + + val metrics = filter.requestLatency.collect() + metrics must have size 1 + val samples = metrics.get(0).samples + //this is the count sample + val countSample = samples.get(samples.size() - 2) + countSample.value mustBe 1.0 + countSample.labelValues must have size 1 + countSample.labelValues.get(0) mustBe "test" + } + } + +} diff --git a/test/play/prometheus/filters/StatusCounterFilterSpec.scala b/test/play/prometheus/filters/StatusCounterFilterSpec.scala new file mode 100644 index 0000000..5d94042 --- /dev/null +++ b/test/play/prometheus/filters/StatusCounterFilterSpec.scala @@ -0,0 +1,42 @@ +package play.prometheus.filters + +import io.prometheus.client.CollectorRegistry +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.verify +import org.scalatest.mockito.MockitoSugar +import org.scalatest.{MustMatchers, WordSpec} +import org.scalatestplus.play.guice.GuiceOneAppPerSuite +import play.api.libs.concurrent.Execution.Implicits.defaultContext +import play.api.mvc.{Action, Results} +import play.api.test.{DefaultAwaitTimeout, FakeRequest, FutureAwaits} + +class StatusCounterFilterSpec extends WordSpec with MustMatchers with MockitoSugar with Results with DefaultAwaitTimeout with FutureAwaits with GuiceOneAppPerSuite { + + private implicit val mat = app.materializer + + "Filter constructor" should { + "Add a counter to the prometheus registry" in { + val collectorRegistry = mock[CollectorRegistry] + new StatusCounterFilter(collectorRegistry) + verify(collectorRegistry).register(any()) + } + } + + "Apply method" should { + "Count the requests with status" in { + val filter = new StatusCounterFilter(mock[CollectorRegistry]) + val rh = FakeRequest() + val action = Action(Ok("success")) + + await(filter(action)(rh).run()) + + val metrics = filter.requestCounter.collect() + metrics must have size 1 + val samples = metrics.get(0).samples + samples.get(0).value mustBe 1.0 + samples.get(0).labelValues must have size 1 + samples.get(0).labelValues.get(0) mustBe "200" + } + } + +}