✨ Render histograms in a filter
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 19 Nov 2019 21:55:14 +0000 (22:55 +0100)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Tue, 19 Nov 2019 21:55:14 +0000 (22:55 +0100)
src/main/kotlin/net/pterodactylus/sone/template/HistogramRenderer.kt [new file with mode: 0644]
src/main/kotlin/net/pterodactylus/sone/web/WebInterfaceModule.kt
src/main/kotlin/net/pterodactylus/sone/web/pages/MetricsPage.kt
src/main/resources/i18n/sone.de.properties
src/main/resources/i18n/sone.en.properties
src/main/resources/templates/metrics.html
src/test/kotlin/net/pterodactylus/sone/template/HistogramRendererTest.kt [new file with mode: 0644]
src/test/kotlin/net/pterodactylus/sone/web/WebInterfaceModuleTest.kt
src/test/kotlin/net/pterodactylus/sone/web/pages/MetricsPageTest.kt

diff --git a/src/main/kotlin/net/pterodactylus/sone/template/HistogramRenderer.kt b/src/main/kotlin/net/pterodactylus/sone/template/HistogramRenderer.kt
new file mode 100644 (file)
index 0000000..6816135
--- /dev/null
@@ -0,0 +1,46 @@
+package net.pterodactylus.sone.template
+
+import com.codahale.metrics.*
+import net.pterodactylus.sone.utils.*
+import net.pterodactylus.util.template.*
+
+/**
+ * [Filter] that renders a [Histogram] as a table row.
+ */
+class HistogramRenderer : Filter {
+
+       override fun format(templateContext: TemplateContext, data: Any?, parameters: Map<String, Any?>?): Any? {
+               templateContext["metricName"] = (parameters?.get("name") as String?)?.dotToCamel()?.let { "Page.Metrics.$it.Title" }
+               (data as? Histogram)?.snapshot?.run {
+                       templateContext["count"] = size()
+                       templateContext["min"] = min
+                       templateContext["max"] = max
+                       templateContext["mean"] = mean
+                       templateContext["median"] = median
+                       templateContext["percentile75"] = get75thPercentile()
+                       templateContext["percentile95"] = get95thPercentile()
+                       templateContext["percentile98"] = get98thPercentile()
+                       templateContext["percentile99"] = get99thPercentile()
+                       templateContext["percentile999"] = get999thPercentile()
+               }
+               return template.render(templateContext)
+       }
+
+}
+
+private val template = """<tr>
+       <td><% metricName|l10n|html></td>
+       <td class="numeric"><% count|html></td>
+       <td class="numeric"><% min|duration scale=='μs'|html></td>
+       <td class="numeric"><% max|duration scale=='μs'|html></td>
+       <td class="numeric"><% mean|duration scale=='μs'|html></td>
+       <td class="numeric"><% median|duration scale=='μs'|html></td>
+       <td class="numeric"><% percentile75|duration scale=='μs'|html></td>
+       <td class="numeric"><% percentile95|duration scale=='μs'|html></td>
+       <td class="numeric"><% percentile98|duration scale=='μs'|html></td>
+       <td class="numeric"><% percentile99|duration scale=='μs'|html></td>
+       <td class="numeric"><% percentile999|duration scale=='μs'|html></td>
+</tr>""".asTemplate()
+
+private fun String.dotToCamel() =
+               split(".").joinToString("", transform = String::capitalize)
index f79eff5..c996e30 100644 (file)
@@ -72,6 +72,7 @@ class WebInterfaceModule : AbstractModule() {
                                addFilter("unique", UniqueElementFilter())
                                addFilter("mod", ModFilter())
                                addFilter("paginate", PaginationFilter())
+                               addFilter("render-histogram", HistogramRenderer())
 
                                addProvider(TemplateProvider.TEMPLATE_CONTEXT_PROVIDER)
                                addProvider(loaders.templateProvider)
index 655c912..837e2c8 100644 (file)
@@ -13,41 +13,7 @@ import javax.inject.*
 class MetricsPage @Inject constructor(webInterface: WebInterface, loaders: Loaders, templateRenderer: TemplateRenderer, private val metricsRegistry: MetricRegistry) : SoneTemplatePage(webInterface, loaders, templateRenderer, "Page.Metrics.Title") {
 
        override fun handleRequest(soneRequest: SoneRequest, templateContext: TemplateContext) {
-               metricsRegistry.histograms
-                               .mapKeys { it.key to it.key.toI18n() }
-                               .onEach { addHistogram(templateContext, it.key.first, it.key.second) }
-                               .keys
-                               .map(Pair<*, String>::second)
-                               .let { templateContext["histogramKeys"] = it }
-       }
-
-       private fun addHistogram(templateContext: TemplateContext, metricName: String, variablePrefix: String) {
-               metricsRegistry.histogram(metricName).also { histogram ->
-                       templateContext["${variablePrefix}I18n"] = variablePrefix.capitalizeFirst()
-                       templateContext["${variablePrefix}Count"] = histogram.count
-                       histogram.snapshot.also { snapshot ->
-                               templateContext["${variablePrefix}Min"] = snapshot.min
-                               templateContext["${variablePrefix}Max"] = snapshot.max
-                               templateContext["${variablePrefix}Median"] = snapshot.median
-                               templateContext["${variablePrefix}Mean"] = snapshot.mean
-                               templateContext["${variablePrefix}Percentile75"] = snapshot.get75thPercentile()
-                               templateContext["${variablePrefix}Percentile95"] = snapshot.get95thPercentile()
-                               templateContext["${variablePrefix}Percentile98"] = snapshot.get98thPercentile()
-                               templateContext["${variablePrefix}Percentile99"] = snapshot.get99thPercentile()
-                               templateContext["${variablePrefix}Percentile999"] = snapshot.get999thPercentile()
-                       }
-               }
+               templateContext["histograms"] = metricsRegistry.histograms
        }
 
 }
-
-private fun String.toI18n() = split(".")
-               .mapIndexed { index, s -> if (index > 0) s.capitalizeFirst() else s }
-               .joinToString("")
-
-private fun String.capitalizeFirst() =
-               get(0).toUpperCase() + if (length > 0) {
-                       substring(1)
-               } else {
-                       ""
-               }
index b4c68d7..32e73cf 100644 (file)
@@ -331,6 +331,12 @@ Page.Invalid.Title=Ungültige Aktion ausgeführt - Sone
 Page.Invalid.Page.Title=Ungültige Aktion ausgeführt
 Page.Invalid.Text=Eine ungültige Aktion wurde ausgeführt, oder eine gültige Aktion hatte ungültige Parameter. Bitte kehren Sie zur {link}Hauptseite{/link} zurück und versuchen Sie Ihre Aktion erneut. Wenn der Fehler weiterhin besteht, haben Sie wahrscheinlich einen Programmierfehler gefunden.
 
+Page.Metrics.Title=Metriken
+Page.Metrics.Page.Title=Metriken
+Page.Metrics.SoneInsertDuration.Title=Hochladedauer einer Sone
+Page.Metrics.SoneParsingDuration.Title=Parsdauer einer Sone
+Page.Metrics.ConfigurationSavingDuration.Title=Speicherdauer der Konfiguration
+
 View.Search.Button.Search=Suchen
 
 View.CreateSone.Text.WotIdentityRequired=Um eine Sone anzulegen, brauchen Sie eine Identität aus dem {link}„Web of Trust“ Plugin{/link}.
index c539fe2..0b7cbf8 100644 (file)
@@ -337,6 +337,7 @@ Page.Metrics.Title=Metrics
 Page.Metrics.Page.Title=Metrics
 Page.Metrics.SoneInsertDuration.Title=Sone Insert Duration
 Page.Metrics.SoneParsingDuration.Title=Sone Parsing Duration
+Page.Metrics.ConfigurationSavingDuration.Title=Configuration Saving Duration
 
 View.Search.Button.Search=Search
 
index e69ea19..25052d5 100644 (file)
                        </tr>
                </thead>
                <tbody>
-                       <%foreach histogramKeys histogramKey>
-                               <tr>
-                                       <td><%= "Page.Metrics.<%= '<%histogramKey>I18n'|parse>.Title"|parse|l10n|html></td>
-                                       <td class="numeric"><% "<%histogramKey>Count"|parse|html></td>
-                                       <td class="numeric"><% "<%histogramKey>Min"|parse|duration scale=="μs"|html></td>
-                                       <td class="numeric"><% "<%histogramKey>Max"|parse|duration scale=="μs"|html></td>
-                                       <td class="numeric"><% "<%histogramKey>Mean"|parse|duration scale=="μs"|html></td>
-                                       <td class="numeric"><% "<%histogramKey>Median"|parse|duration scale=="μs"|html></td>
-                                       <td class="numeric"><% "<%histogramKey>Percentile75"|parse|duration scale=="μs"|html></td>
-                                       <td class="numeric"><% "<%histogramKey>Percentile95"|parse|duration scale=="μs"|html></td>
-                                       <td class="numeric"><% "<%histogramKey>Percentile98"|parse|duration scale=="μs"|html></td>
-                                       <td class="numeric"><% "<%histogramKey>Percentile99"|parse|duration scale=="μs"|html></td>
-                                       <td class="numeric"><% "<%histogramKey>Percentile999"|parse|duration scale=="μs"|html></td>
-                               </tr>
+                       <%foreach histograms histogram>
+                               <% histogram.value|render-histogram name=histogram.key>
                        <%/foreach>
                </tbody>
        </table>
diff --git a/src/test/kotlin/net/pterodactylus/sone/template/HistogramRendererTest.kt b/src/test/kotlin/net/pterodactylus/sone/template/HistogramRendererTest.kt
new file mode 100644 (file)
index 0000000..592c3ef
--- /dev/null
@@ -0,0 +1,204 @@
+/**
+ * Sone - HistogramRendererTest.kt - Copyright © 2019 David ‘Bombe’ Roden
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.pterodactylus.sone.template
+
+import com.codahale.metrics.*
+import net.pterodactylus.sone.freenet.*
+import net.pterodactylus.util.template.*
+import org.hamcrest.MatcherAssert.*
+import org.hamcrest.Matchers.*
+import org.jsoup.*
+import org.jsoup.nodes.*
+import org.junit.*
+import java.util.*
+
+/**
+ * Unit test for [HistogramRenderer].
+ */
+class HistogramRendererTest {
+
+       private val translation = object : Translation {
+               override val currentLocale = Locale.ENGLISH
+               override fun translate(key: String) = "Metric Name".takeIf { key == "Page.Metrics.TestHistogram.Title" } ?: ""
+       }
+       private val metricRenderer = HistogramRenderer()
+       private val templateContext = TemplateContext().apply {
+               addFilter("html", HtmlFilter())
+               addFilter("duration", DurationFormatFilter())
+               addFilter("l10n", L10nFilter(translation))
+       }
+
+       @Test
+       fun `histogram is rendered as table row`() {
+               createAndVerifyTableRow {
+                       assertThat(it.nodeName(), equalTo("tr"))
+               }
+       }
+
+       @Test
+       fun `histogram has eleven columns`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td"), hasSize(11))
+               }
+       }
+
+       @Test
+       fun `first column contains translated metric name`() {
+               createAndVerifyTableRow(mapOf("name" to "test.histogram")) {
+                       assertThat(it.getElementsByTag("td")[0].text(), equalTo("Metric Name"))
+               }
+       }
+
+       @Test
+       fun `second column is numeric`() {
+               verifyColumnIsNumeric(1)
+       }
+
+       @Test
+       fun `second column contains count`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td")[1].text(), equalTo("1001"))
+               }
+       }
+
+       @Test
+       fun `third column is numeric`() {
+               verifyColumnIsNumeric(2)
+       }
+
+       @Test
+       fun `third column contains min value`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td")[2].text(), equalTo("3.4ms"))
+               }
+       }
+
+       @Test
+       fun `fourth column is numeric`() {
+               verifyColumnIsNumeric(3)
+       }
+
+       @Test
+       fun `fourth column contains max value`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td")[3].text(), equalTo("998.9ms"))
+               }
+       }
+
+       @Test
+       fun `fifth column is numeric`() {
+               verifyColumnIsNumeric(4)
+       }
+
+       @Test
+       fun `fifth column contains mean value`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td")[4].text(), equalTo("503.4ms"))
+               }
+       }
+
+       @Test
+       fun `sixth column is numeric`() {
+               verifyColumnIsNumeric(5)
+       }
+
+       @Test
+       fun `sixth column contains median value`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td")[5].text(), equalTo("501.0ms"))
+               }
+       }
+
+       @Test
+       fun `seventh column is numeric`() {
+               verifyColumnIsNumeric(6)
+       }
+
+       @Test
+       fun `seventh column contains 75th percentile`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td")[6].text(), equalTo("757.8ms"))
+               }
+       }
+
+       @Test
+       fun `eighth column is numeric`() {
+               verifyColumnIsNumeric(7)
+       }
+
+       @Test
+       fun `eighth column contains 95th percentile`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td")[7].text(), equalTo("948.6ms"))
+               }
+       }
+
+       @Test
+       fun `ninth column is numeric`() {
+               verifyColumnIsNumeric(8)
+       }
+
+       @Test
+       fun `ninth column contains 98th percentile`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td")[8].text(), equalTo("972.7ms"))
+               }
+       }
+
+       @Test
+       fun `tenth column is numeric`() {
+               verifyColumnIsNumeric(9)
+       }
+
+       @Test
+       fun `tenth column contains 99th percentile`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td")[9].text(), equalTo("986.4ms"))
+               }
+       }
+
+       @Test
+       fun `eleventh column is numeric`() {
+               verifyColumnIsNumeric(10)
+       }
+
+       @Test
+       fun `eleventh column contains 99,9th percentile`() {
+               createAndVerifyTableRow {
+                       assertThat(it.getElementsByTag("td")[10].text(), equalTo("998.5ms"))
+               }
+       }
+
+       private fun createAndVerifyTableRow(parameters: Map<String, Any?>? = null, verify: (Element) -> Unit) =
+                       metricRenderer.format(templateContext, histogram, parameters)
+                                       .let { "<table id='t'>$it</table>" }
+                                       .let(Jsoup::parseBodyFragment)
+                                       .getElementById("t").child(0).child(0)
+                                       .let(verify)
+
+       private fun verifyColumnIsNumeric(column: Int) =
+                       createAndVerifyTableRow {
+                               assertThat(it.getElementsByTag("td")[column].classNames(), hasItem("numeric"))
+                       }
+
+}
+
+private val random = Random(1)
+private val histogram = MetricRegistry().histogram("test.histogram").apply {
+       (0..1000).map { random.nextInt(1_000_000) }.forEach(this::update)
+}
index 4d8fc83..14427ef 100644 (file)
@@ -243,6 +243,11 @@ class WebInterfaceModuleTest {
                verifyFilter<PaginationFilter>("paginate")
        }
 
+       @Test
+       fun `template context histogram renderer`() {
+               verifyFilter<HistogramRenderer>("render-histogram")
+       }
+
        private inline fun <reified F : Filter> verifyFilter(name: String) {
                assertThat(getFilter(name), instanceOf(F::class.java))
        }
index 5a5d588..9e2c492 100644 (file)
@@ -62,46 +62,16 @@ class MetricsPageTest : WebPageTest() {
        }
 
        @Test
-       fun `metrics page auto-converts histogram name`() {
-               createHistogram("sone.random.duration")
-               page.handleRequest(soneRequest, templateContext)
-               verifyHistogram("soneRandomDuration")
-       }
-
-       @Test
        @Suppress("UNCHECKED_CAST")
-       fun `metrics page stores histogram keys in template`() {
+       fun `metrics page stores histograms in template context`() {
                createHistogram("sone.random.duration2")
                createHistogram("sone.random.duration1")
                page.handleRequest(soneRequest, templateContext)
-               assertThat(templateContext["histogramKeys"] as Iterable<String>, contains("soneRandomDuration1", "soneRandomDuration2"))
-       }
-
-       @Test
-       fun `metrics page stores i18n names for histogram keys`() {
-               createHistogram("sone.random.duration1")
-               page.handleRequest(soneRequest, templateContext)
-               assertThat(templateContext["soneRandomDuration1I18n"] as String, equalTo("SoneRandomDuration1"))
-       }
-
-       @Test
-       fun `metrics page delivers correct histogram size`() {
-               val histogram = metricRegistry.histogram("sone.parsing.duration")
-               (0..4000).forEach(histogram::update)
-               page.handleRequest(soneRequest, templateContext)
-               assertThat(templateContext["soneParsingDurationCount"] as Long, equalTo(4001L))
-       }
-
-       private fun verifyHistogram(name: String) {
-               assertThat(templateContext["${name}Count"] as Long, equalTo(5L))
-               assertThat(templateContext["${name}Min"] as Long, equalTo(1L))
-               assertThat(templateContext["${name}Max"] as Long, equalTo(10L))
-               assertThat(templateContext["${name}Median"] as Double, equalTo(8.0))
-               assertThat(templateContext["${name}Percentile75"] as Double, equalTo(9.0))
-               assertThat(templateContext["${name}Percentile95"] as Double, equalTo(10.0))
-               assertThat(templateContext["${name}Percentile98"] as Double, equalTo(10.0))
-               assertThat(templateContext["${name}Percentile99"] as Double, equalTo(10.0))
-               assertThat(templateContext["${name}Percentile999"] as Double, equalTo(10.0))
+               val histograms = templateContext["histograms"] as Map<String, Histogram>
+               assertThat(histograms.entries.map { it.key to it.value }, containsInAnyOrder(
+                               "sone.random.duration1" to metricRegistry.histogram("sone.random.duration1"),
+                               "sone.random.duration2" to metricRegistry.histogram("sone.random.duration2")
+               ))
        }
 
        private fun createHistogram(name: String) = metricRegistry.histogram(name).run {