From 0b538ce005b1f4737b657e5d936406ced8202467 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Thu, 13 Feb 2025 11:29:36 -0600 Subject: [PATCH 1/6] feat: Support labels for CounterMetric. --- .../kotlin/org/jitsi/metrics/CounterMetric.kt | 72 ++++++++++++++++--- .../main/kotlin/org/jitsi/metrics/Metric.kt | 2 + .../org/jitsi/metrics/MetricsContainer.kt | 11 ++- .../kotlin/org/jitsi/metrics/MetricTest.kt | 62 ++++++++++++++++ 4 files changed, 133 insertions(+), 14 deletions(-) diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/CounterMetric.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/CounterMetric.kt index 7e37f76f..bd9d74c3 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/CounterMetric.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/CounterMetric.kt @@ -19,7 +19,7 @@ import io.prometheus.client.CollectorRegistry import io.prometheus.client.Counter /** - * A long metric wrapper for Prometheus [Counters][Counter], which are monotonically increasing. + * A long metric wrapper for a Prometheus [Counter], which is monotonically increasing. * Provides atomic operations such as [incAndGet]. * * @see [Prometheus Counter](https://prometheus.io/docs/concepts/metric_types/.counter) @@ -34,16 +34,40 @@ class CounterMetric @JvmOverloads constructor( /** the namespace (prefix) of this metric */ namespace: String, /** an optional initial value for this metric */ - internal val initialValue: Long = 0L + internal val initialValue: Long = 0L, + /** Label names for this metric. If non-empty, the initial value must be 0 and all get/update calls MUST + * specify values for the labels. Calls to simply [get()] or [inc()] will fail with an exception. */ + val labelNames: List = emptyList() ) : Metric() { - private val counter = - Counter.build(name, help).namespace(namespace).create().apply { inc(initialValue.toDouble()) } + private val counter = run { + val builder = Counter.build(name, help).namespace(namespace) + if (labelNames.isNotEmpty()) { + builder.labelNames(*labelNames.toTypedArray()) + if (initialValue != 0L) { + throw IllegalArgumentException("Cannot set an initial value for a labeled counter") + } + } + builder.create().apply { + if (initialValue != 0L) { + inc(initialValue.toDouble()) + } + } + } + + /** When we have labels [get()] throws an exception and the JSON format is not supported. */ + override val supportsJson: Boolean = labelNames.isEmpty() override fun get() = counter.get().toLong() + fun get(labels: List) = counter.labels(*labels.toTypedArray()).get().toLong() override fun reset() { synchronized(counter) { - counter.apply { clear() }.inc(initialValue.toDouble()) + counter.apply { + clear() + if (initialValue != 0L) { + inc(initialValue.toDouble()) + } + } } } @@ -52,16 +76,27 @@ class CounterMetric @JvmOverloads constructor( /** * Atomically adds the given value to this counter. */ - fun add(delta: Long) = synchronized(counter) { counter.inc(delta.toDouble()) } + fun add(delta: Long, labels: List = emptyList()) = synchronized(counter) { + if (labels.isEmpty()) { + counter.inc(delta.toDouble()) + } else { + counter.labels(*labels.toTypedArray()).inc(delta.toDouble()) + } + } /** * Atomically adds the given value to this counter, returning the updated value. * * @return the updated value */ - fun addAndGet(delta: Long): Long = synchronized(counter) { - counter.inc(delta.toDouble()) - return counter.get().toLong() + fun addAndGet(delta: Long, labels: List = emptyList()): Long = synchronized(counter) { + return if (labels.isEmpty()) { + counter.inc(delta.toDouble()) + counter.get().toLong() + } else { + counter.labels(*labels.toTypedArray()).inc(delta.toDouble()) + counter.labels(*labels.toTypedArray()).get().toLong() + } } /** @@ -69,10 +104,25 @@ class CounterMetric @JvmOverloads constructor( * * @return the updated value */ - fun incAndGet() = addAndGet(1) + fun incAndGet(labels: List = emptyList()) = addAndGet(1, labels) /** * Atomically increments the value of this counter by one. */ - fun inc() = synchronized(counter) { counter.inc() } + fun inc(labels: List = emptyList()) = synchronized(counter) { + if (labels.isEmpty()) { + counter.inc() + } else { + counter.labels(*labels.toTypedArray()).inc() + } + } + + /** Remove the child with the given labels (the metric with those labels will stop being emitted) */ + fun remove(labels: List = emptyList()) = synchronized(counter) { + if (labels.isNotEmpty()) { + counter.remove(*labels.toTypedArray()) + } + } + + internal fun collect() = counter.collect() } diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/Metric.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/Metric.kt index 7cdd3b14..20cbc062 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/Metric.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/Metric.kt @@ -44,4 +44,6 @@ sealed class Metric { * Registers this metric with the given [CollectorRegistry] and returns it. */ internal abstract fun register(registry: CollectorRegistry): Metric + + internal open val supportsJson: Boolean = true } diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt index 70a35ef1..2a687a1c 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt @@ -53,7 +53,7 @@ open class MetricsContainer @JvmOverloads constructor( * @return a JSON string of the metrics in this instance */ open val jsonString: String - get() = JSONObject(metrics.mapValues { it.value.get() }).toJSONString() + get() = JSONObject(metrics.filter { it.value.supportsJson }.mapValues { it.value.get() }).toJSONString() /** * Returns the metrics in this instance in the Prometheus text-based format. @@ -143,7 +143,10 @@ open class MetricsContainer @JvmOverloads constructor( /** the description of the metric */ help: String, /** the optional initial value of the metric */ - initialValue: Long = 0 + initialValue: Long = 0, + /** Label names for this metric. If non-empty, the initial value must be 0 and all get/update calls MUST + * specify values for the labels. Calls to simply get() or inc() will fail with an exception. */ + labelNames: List = emptyList() ): CounterMetric { val newName = if (name.endsWith("_total")) { name @@ -158,7 +161,9 @@ open class MetricsContainer @JvmOverloads constructor( } return metrics[newName] as CounterMetric } - return CounterMetric(newName, help, namespace, initialValue).apply { metrics[newName] = register(registry) } + return CounterMetric(newName, help, namespace, initialValue, labelNames).apply { + metrics[newName] = register(registry) + } } /** diff --git a/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt b/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt index 8cf58669..b3793801 100644 --- a/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt +++ b/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt @@ -71,6 +71,7 @@ class MetricTest : ShouldSpec() { incAndGet() shouldBe 1 repeat(20) { inc() } get() shouldBe 21 + supportsJson shouldBe true } } context("and decrementing its value") { @@ -94,6 +95,67 @@ class MetricTest : ShouldSpec() { } } } + context("With labels") { + context("With initialValue != 0") { + shouldThrow { + CounterMetric("name", "help", namespace, 1, listOf("l1")) + } + } + with(CounterMetric("testCounter", "Help", namespace, labelNames = listOf("l1", "l2"))) { + supportsJson shouldBe false + listOf( + { get() }, + { get(listOf("v1")) }, + { get(listOf("v1", "v2", "v3")) }, + { inc() }, + { inc(listOf("v1")) }, + { inc(listOf("v1", "v2", "v3")) }, + { add(3) }, + { add(3, listOf("v1")) }, + { add(3, listOf("v1", "v2", "v3")) }, + ).forEach { block -> + shouldThrow { + block() + } + } + + val labels = listOf("A", "A") + val labels2 = listOf("A", "B") + val labels3 = listOf("B", "B") + + get(labels) shouldBe 0 + get(labels2) shouldBe 0 + get(labels3) shouldBe 0 + + addAndGet(3, labels) shouldBe 3 + get(labels) shouldBe 3 + get(labels2) shouldBe 0 + get(labels3) shouldBe 0 + + inc(labels2) + get(labels) shouldBe 3 + get(labels2) shouldBe 1 + get(labels3) shouldBe 0 + + incAndGet(labels3) shouldBe 1 + + add(2, labels) + get(labels) shouldBe 5 + get(labels2) shouldBe 1 + get(labels3) shouldBe 1 + + // _total and _created for 3 sets of labels + collect()[0].samples.size shouldBe 6 + remove(labels2) + // Down to two sets of labels + collect()[0].samples.size shouldBe 4 + get(labels) shouldBe 5 + get(labels2) shouldBe 0 + get(labels3) shouldBe 1 + // Even a get() will summon a child + collect()[0].samples.size shouldBe 6 + } + } } context("Creating a LongGaugeMetric") { context("with the default initial value") { From fe677b3e7a48d7e552a6c16260a20c85a566af80 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Thu, 13 Feb 2025 17:04:43 -0600 Subject: [PATCH 2/6] feat: Add labels support to DoubleGaugeMetric. --- .../org/jitsi/metrics/DoubleGaugeMetric.kt | 78 ++++++++++++++++--- .../org/jitsi/metrics/MetricsContainer.kt | 9 ++- .../kotlin/org/jitsi/metrics/MetricTest.kt | 62 +++++++++++++++ 3 files changed, 135 insertions(+), 14 deletions(-) diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/DoubleGaugeMetric.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/DoubleGaugeMetric.kt index 51f4db51..16be1c99 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/DoubleGaugeMetric.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/DoubleGaugeMetric.kt @@ -32,29 +32,67 @@ class DoubleGaugeMetric @JvmOverloads constructor( /** the namespace (prefix) of this metric */ namespace: String, /** an optional initial value for this metric */ - internal val initialValue: Double = 0.0 + internal val initialValue: Double = 0.0, + /** Label names for this metric. If non-empty, the initial value must be 0 and all get/update calls MUST + * specify values for the labels. Calls to simply [get()] or [set(Double)] will fail with an exception. */ + val labelNames: List = emptyList() ) : Metric() { - private val gauge = Gauge.build(name, help).namespace(namespace).create().apply { set(initialValue) } + private val gauge = run { + val builder = Gauge.build(name, help).namespace(namespace) + if (labelNames.isNotEmpty()) { + builder.labelNames(*labelNames.toTypedArray()) + if (initialValue != 0.0) { + throw IllegalArgumentException("Cannot set an initial value for a labeled gauge") + } + } + builder.create().apply { + if (initialValue != 0.0) { + set(initialValue) + } + } + } + + /** When we have labels [get()] throws an exception and the JSON format is not supported. */ + override val supportsJson: Boolean = labelNames.isEmpty() override fun get() = gauge.get() + fun get(labelNames: List) = gauge.labels(*labelNames.toTypedArray()).get() - override fun reset() = set(initialValue) + override fun reset() = synchronized(gauge) { + gauge.clear() + if (initialValue != 0.0) { + gauge.set(initialValue) + } + } override fun register(registry: CollectorRegistry) = this.also { registry.register(gauge) } /** * Sets the value of this gauge to the given value. */ - fun set(newValue: Double) = gauge.set(newValue) + fun set(newValue: Double, labels: List = emptyList()) { + if (labels.isEmpty()) { + gauge.set(newValue) + } else { + gauge.labels(*labels.toTypedArray()).set(newValue) + } + } /** * Atomically sets the gauge to the given value, returning the updated value. * * @return the updated value */ - fun setAndGet(newValue: Double): Double = synchronized(gauge) { - gauge.set(newValue) - return gauge.get() + fun setAndGet(newValue: Double, labels: List = emptyList()): Double = synchronized(gauge) { + return if (labels.isEmpty()) { + gauge.set(newValue) + gauge.get() + } else { + with(gauge.labels(*labels.toTypedArray())) { + set(newValue) + get() + } + } } /** @@ -62,9 +100,16 @@ class DoubleGaugeMetric @JvmOverloads constructor( * * @return the updated value */ - fun addAndGet(delta: Double): Double = synchronized(gauge) { - gauge.inc(delta) - return gauge.get() + fun addAndGet(delta: Double, labels: List = emptyList()): Double = synchronized(gauge) { + return if (labels.isEmpty()) { + gauge.inc(delta) + gauge.get() + } else { + with(gauge.labels(*labels.toTypedArray())) { + inc(delta) + get() + } + } } /** @@ -72,12 +117,21 @@ class DoubleGaugeMetric @JvmOverloads constructor( * * @return the updated value */ - fun incAndGet() = addAndGet(1.0) + fun incAndGet(labels: List = emptyList()) = addAndGet(1.0, labels) /** * Atomically decrements the value of this gauge by one, returning the updated value. * * @return the updated value */ - fun decAndGet() = addAndGet(-1.0) + fun decAndGet(labels: List = emptyList()) = addAndGet(-1.0, labels) + + /** Remove the child with the given labels (the metric with those labels will stop being emitted) */ + fun remove(labels: List = emptyList()) = synchronized(gauge) { + if (labels.isNotEmpty()) { + gauge.remove(*labels.toTypedArray()) + } + } + + internal fun collect() = gauge.collect() } diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt index 2a687a1c..52cba6ef 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt @@ -201,7 +201,10 @@ open class MetricsContainer @JvmOverloads constructor( /** the description of the metric */ help: String, /** the optional initial value of the metric */ - initialValue: Double = 0.0 + initialValue: Double = 0.0, + /** Label names for this metric. If non-empty, the initial value must be 0 and all get/update calls MUST + * specify values for the labels. Calls to simply get() or set() will fail with an exception. */ + labelNames: List = emptyList() ): DoubleGaugeMetric { if (metrics.containsKey(name)) { if (checkForNameConflicts) { @@ -209,7 +212,9 @@ open class MetricsContainer @JvmOverloads constructor( } return metrics[name] as DoubleGaugeMetric } - return DoubleGaugeMetric(name, help, namespace, initialValue).apply { metrics[name] = register(registry) } + return DoubleGaugeMetric(name, help, namespace, initialValue, labelNames).apply { + metrics[name] = register(registry) + } } /** diff --git a/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt b/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt index b3793801..50aa8f32 100644 --- a/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt +++ b/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt @@ -62,6 +62,68 @@ class MetricTest : ShouldSpec() { } } } + context("Creating a DoubleGaugeMetric") { + context("with the default initial value") { + with(DoubleGaugeMetric("testDoubleGauge", "Help", namespace)) { + context("and affecting its value repeatedly") { + should("return the correct value") { + get() shouldBe 0.0 + incAndGet().also { addAndGet(-1.0) } + get() shouldBe 0.0 + decAndGet() shouldBe -1.0 + incAndGet() shouldBe 0.0 + addAndGet(50.0) shouldBe 50.0 + set(42.0).also { get() shouldBe 42.0 } + set(-42.0).also { get() shouldBe -42.0 } + } + } + } + } + context("with a given initial value") { + val initialValue: Double = -50.0 + with(DoubleGaugeMetric("testDoubleGauge", "Help", namespace, initialValue)) { + should("return the initial value") { get() shouldBe initialValue } + } + } + context("with labels") { + with(DoubleGaugeMetric("testDoubleGauge", "Help", namespace, labelNames = listOf("l1", "l2"))) { + val labels = listOf("A", "A") + val labels2 = listOf("A", "B") + val labels3 = listOf("B", "B") + + get(labels) shouldBe 0.0 + get(labels2) shouldBe 0.0 + get(labels3) shouldBe 0.0 + + addAndGet(3.0, labels) shouldBe 3.0 + get(labels) shouldBe 3.0 + get(labels2) shouldBe 0.0 + get(labels3) shouldBe 0.0 + + incAndGet(labels2) + get(labels) shouldBe 3.0 + get(labels2) shouldBe 1.0 + get(labels3) shouldBe 0.0 + + incAndGet(labels3) shouldBe 1.0 + + addAndGet(2.0, labels) + get(labels) shouldBe 5.0 + get(labels2) shouldBe 1.0 + get(labels3) shouldBe 1.0 + + collect()[0].samples.size shouldBe 3 + remove(labels2) + // Down to two sets of labels + collect()[0].samples.size shouldBe 2 + get(labels) shouldBe 5.0 + get(labels2) shouldBe 0.0 + get(labels3) shouldBe 1.0 + // Even a get() will summon a child + collect()[0].samples.size shouldBe 3 + } + } + } context("Creating a CounterMetric") { context("with the default initial value") { with(CounterMetric("testCounter", "Help", namespace)) { From 40cb7596fda0d8b9fe400702eb5d69ec474f1680 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Tue, 18 Feb 2025 14:24:41 -0600 Subject: [PATCH 3/6] feat: Add labels support to LongGaugeMetric. --- .../org/jitsi/metrics/LongGaugeMetric.kt | 79 ++++++++++++++++--- .../org/jitsi/metrics/MetricsContainer.kt | 9 ++- .../kotlin/org/jitsi/metrics/MetricTest.kt | 38 +++++++++ 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/LongGaugeMetric.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/LongGaugeMetric.kt index 8ad39d15..42078320 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/LongGaugeMetric.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/LongGaugeMetric.kt @@ -32,39 +32,88 @@ class LongGaugeMetric @JvmOverloads constructor( /** the namespace (prefix) of this metric */ namespace: String, /** an optional initial value for this metric */ - internal val initialValue: Long = 0L + internal val initialValue: Long = 0L, + /** Label names for this metric. If non-empty, the initial value must be 0 and all get/update calls MUST + * specify values for the labels. Calls to simply get() or set() will fail with an exception. */ + val labelNames: List = emptyList() ) : Metric() { - private val gauge = Gauge.build(name, help).namespace(namespace).create().apply { set(initialValue.toDouble()) } + private val gauge = run { + val builder = Gauge.build(name, help).namespace(namespace) + if (labelNames.isNotEmpty()) { + builder.labelNames(*labelNames.toTypedArray()) + if (initialValue != 0L) { + throw IllegalArgumentException("Cannot set an initial value for a labeled gauge") + } + } + builder.create().apply { + if (initialValue != 0L) { + set(initialValue.toDouble()) + } + } + } + /** When we have labels [get()] throws an exception and the JSON format is not supported. */ + override val supportsJson: Boolean = labelNames.isEmpty() override fun get() = gauge.get().toLong() + fun get(labels: List) = gauge.labels(*labels.toTypedArray()).get().toLong() - override fun reset() = set(initialValue) + override fun reset() = synchronized(gauge) { + gauge.clear() + if (initialValue != 0L) { + gauge.inc(initialValue.toDouble()) + } + } override fun register(registry: CollectorRegistry) = this.also { registry.register(gauge) } /** * Atomically sets the gauge to the given value. */ - fun set(newValue: Long): Unit = synchronized(gauge) { gauge.set(newValue.toDouble()) } + fun set(newValue: Long, labels: List = emptyList()): Unit = synchronized(gauge) { + if (labels.isEmpty()) { + gauge.set(newValue.toDouble()) + } else { + gauge.labels(*labels.toTypedArray()).set(newValue.toDouble()) + } + } /** * Atomically increments the value of this gauge by one. */ - fun inc() = synchronized(gauge) { gauge.inc() } + fun inc(labels: List = emptyList()) = synchronized(gauge) { + if (labels.isEmpty()) { + gauge.inc() + } else { + gauge.labels(*labels.toTypedArray()).inc() + } + } /** * Atomically decrements the value of this gauge by one. */ - fun dec() = synchronized(gauge) { gauge.dec() } + fun dec(labels: List = emptyList()) = synchronized(gauge) { + if (labels.isEmpty()) { + gauge.dec() + } else { + gauge.labels(*labels.toTypedArray()).dec() + } + } /** * Atomically adds the given value to this gauge, returning the updated value. * * @return the updated value */ - fun addAndGet(delta: Long): Long = synchronized(gauge) { - gauge.inc(delta.toDouble()) - return gauge.get().toLong() + fun addAndGet(delta: Long, labels: List = emptyList()): Long = synchronized(gauge) { + return if (labels.isEmpty()) { + gauge.inc(delta.toDouble()) + gauge.get().toLong() + } else { + with(gauge.labels(*labels.toTypedArray())) { + inc(delta.toDouble()) + get().toLong() + } + } } /** @@ -72,12 +121,20 @@ class LongGaugeMetric @JvmOverloads constructor( * * @return the updated value */ - fun incAndGet() = addAndGet(1) + fun incAndGet(labels: List = emptyList()) = addAndGet(1, labels) /** * Atomically decrements the value of this gauge by one, returning the updated value. * * @return the updated value */ - fun decAndGet() = addAndGet(-1) + fun decAndGet(labels: List = emptyList()) = addAndGet(-1, labels) + + /** Remove the child with the given labels (the metric with those labels will stop being emitted) */ + fun remove(labels: List = emptyList()) = synchronized(gauge) { + if (labels.isNotEmpty()) { + gauge.remove(*labels.toTypedArray()) + } + } + internal fun collect() = gauge.collect() } diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt index 52cba6ef..e4b6e6ef 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt @@ -178,7 +178,10 @@ open class MetricsContainer @JvmOverloads constructor( /** the description of the metric */ help: String, /** the optional initial value of the metric */ - initialValue: Long = 0 + initialValue: Long = 0, + /** Label names for this metric. If non-empty, the initial value must be 0 and all get/update calls MUST + * specify values for the labels. Calls to simply get() or set() will fail with an exception. */ + labelNames: List = emptyList() ): LongGaugeMetric { if (metrics.containsKey(name)) { if (checkForNameConflicts) { @@ -186,7 +189,9 @@ open class MetricsContainer @JvmOverloads constructor( } return metrics[name] as LongGaugeMetric } - return LongGaugeMetric(name, help, namespace, initialValue).apply { metrics[name] = register(registry) } + return LongGaugeMetric(name, help, namespace, initialValue, labelNames).apply { + metrics[name] = register(registry) + } } /** diff --git a/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt b/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt index 50aa8f32..9b15202e 100644 --- a/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt +++ b/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt @@ -241,6 +241,44 @@ class MetricTest : ShouldSpec() { should("return the initial value") { get() shouldBe initialValue } } } + context("With labels") { + with(LongGaugeMetric("testLongGauge", "Help", namespace, labelNames = listOf("l1", "l2"))) { + val labels = listOf("A", "A") + val labels2 = listOf("A", "B") + val labels3 = listOf("B", "B") + + get(labels) shouldBe 0 + get(labels2) shouldBe 0 + get(labels3) shouldBe 0 + + addAndGet(3, labels) shouldBe 3 + get(labels) shouldBe 3 + get(labels2) shouldBe 0 + get(labels3) shouldBe 0 + + incAndGet(labels2) + get(labels) shouldBe 3 + get(labels2) shouldBe 1 + get(labels3) shouldBe 0 + + incAndGet(labels3) shouldBe 1 + + addAndGet(2, labels) + get(labels) shouldBe 5 + get(labels2) shouldBe 1 + get(labels3) shouldBe 1 + + collect()[0].samples.size shouldBe 3 + remove(labels2) + // Down to two sets of labels + collect()[0].samples.size shouldBe 2 + get(labels) shouldBe 5 + get(labels2) shouldBe 0 + get(labels3) shouldBe 1 + // Even a get() will summon a child + collect()[0].samples.size shouldBe 3 + } + } } context("Creating an InfoMetric") { context("with a value different from its name") { From b44005b0b6704494e6e9ec5957314ff23e8738ed Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Tue, 18 Feb 2025 14:30:45 -0600 Subject: [PATCH 4/6] feat: Add labels support to BooleanMetric. --- .../kotlin/org/jitsi/metrics/BooleanMetric.kt | 50 ++++++++++++++++--- .../org/jitsi/metrics/MetricsContainer.kt | 9 +++- .../kotlin/org/jitsi/metrics/MetricTest.kt | 38 ++++++++++++++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/BooleanMetric.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/BooleanMetric.kt index cb9d81b8..42795ad5 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/BooleanMetric.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/BooleanMetric.kt @@ -30,29 +30,65 @@ class BooleanMetric @JvmOverloads constructor( /** the namespace (prefix) of this metric */ namespace: String, /** an optional initial value for this metric */ - internal val initialValue: Boolean = false + internal val initialValue: Boolean = false, + /** Label names for this metric. If non-empty, the initial value must be false and all get/update calls MUST + * specify values for the labels. Calls to simply get() or set() will fail with an exception. */ + val labelNames: List = emptyList() ) : Metric() { - private val gauge = - Gauge.build(name, help).namespace(namespace).create().apply { set(if (initialValue) 1.0 else 0.0) } + private val gauge = run { + val builder = Gauge.build(name, help).namespace(namespace) + if (labelNames.isNotEmpty()) { + builder.labelNames(*labelNames.toTypedArray()) + if (initialValue) { + throw IllegalArgumentException("Cannot set an initial value for a labeled gauge") + } + } + builder.create().apply { + if (initialValue) { + set(1.0) + } + } + } + override val supportsJson: Boolean = labelNames.isEmpty() override fun get() = gauge.get() != 0.0 + fun get(labels: List) = gauge.labels(*labels.toTypedArray()).get() != 0.0 - override fun reset() = set(initialValue) + override fun reset() = synchronized(gauge) { + gauge.clear() + if (initialValue) { + gauge.set(1.0) + } + } override fun register(registry: CollectorRegistry) = this.also { registry.register(gauge) } /** * Atomically sets the gauge to the given value. */ - fun set(newValue: Boolean): Unit = synchronized(gauge) { gauge.set(if (newValue) 1.0 else 0.0) } + fun set(newValue: Boolean, labels: List = emptyList()): Unit = synchronized(gauge) { + if (labels.isEmpty()) { + gauge.set(if (newValue) 1.0 else 0.0) + } else { + gauge.labels(*labels.toTypedArray()).set(if (newValue) 1.0 else 0.0) + } + } /** * Atomically sets the gauge to the given value, returning the updated value. * * @return the updated value */ - fun setAndGet(newValue: Boolean): Boolean = synchronized(gauge) { - gauge.set(if (newValue) 1.0 else 0.0) + fun setAndGet(newValue: Boolean, labels: List = emptyList()): Boolean = synchronized(gauge) { + set(newValue, labels) return newValue } + + /** Remove the child with the given labels (the metric with those labels will stop being emitted) */ + fun remove(labels: List = emptyList()) = synchronized(gauge) { + if (labels.isNotEmpty()) { + gauge.remove(*labels.toTypedArray()) + } + } + internal fun collect() = gauge.collect() } diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt index e4b6e6ef..9d0912b4 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt @@ -119,7 +119,10 @@ open class MetricsContainer @JvmOverloads constructor( /** the description of the metric */ help: String, /** the optional initial value of the metric */ - initialValue: Boolean = false + initialValue: Boolean = false, + /** Label names for this metric. If non-empty, the initial value must be 0 and all get/update calls MUST + * specify values for the labels. Calls to simply get() or set() will fail with an exception. */ + labelNames: List = emptyList() ): BooleanMetric { if (metrics.containsKey(name)) { if (checkForNameConflicts) { @@ -127,7 +130,9 @@ open class MetricsContainer @JvmOverloads constructor( } return metrics[name] as BooleanMetric } - return BooleanMetric(name, help, namespace, initialValue).apply { metrics[name] = register(registry) } + return BooleanMetric(name, help, namespace, initialValue, labelNames).apply { + metrics[name] = register(registry) + } } /** diff --git a/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt b/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt index 9b15202e..4f6c05f2 100644 --- a/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt +++ b/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt @@ -61,6 +61,44 @@ class MetricTest : ShouldSpec() { should("return true") { get() shouldBe true } } } + context("With labels") { + with(BooleanMetric("testBoolean", "Help", namespace, labelNames = listOf("l1", "l2"))) { + val labels = listOf("A", "A") + val labels2 = listOf("A", "B") + val labels3 = listOf("B", "B") + + get(labels) shouldBe false + get(labels2) shouldBe false + get(labels3) shouldBe false + + set(true, labels) + get(labels) shouldBe true + get(labels2) shouldBe false + get(labels3) shouldBe false + + set(true, labels2) + get(labels) shouldBe true + get(labels2) shouldBe true + get(labels3) shouldBe false + + setAndGet(true, labels3) shouldBe true + + set(false, labels) + get(labels) shouldBe false + get(labels2) shouldBe true + get(labels3) shouldBe true + + collect()[0].samples.size shouldBe 3 + remove(labels2) + // Down to two sets of labels + collect()[0].samples.size shouldBe 2 + get(labels) shouldBe false + get(labels2) shouldBe false + get(labels3) shouldBe true + // Even a get() will summon a child + collect()[0].samples.size shouldBe 3 + } + } } context("Creating a DoubleGaugeMetric") { context("with the default initial value") { From 82c6af5c52a73dfbac92bbe721278ce047d4d4fd Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Tue, 18 Feb 2025 14:44:41 -0600 Subject: [PATCH 5/6] feat: Add labels support to InfoMetric. --- .../kotlin/org/jitsi/metrics/InfoMetric.kt | 38 ++++++++++++++++--- .../org/jitsi/metrics/MetricsContainer.kt | 7 +++- .../kotlin/org/jitsi/metrics/MetricTest.kt | 28 ++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/InfoMetric.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/InfoMetric.kt index c101b5d9..2a2bd83b 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/InfoMetric.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/InfoMetric.kt @@ -24,7 +24,7 @@ import io.prometheus.client.Info * In the Prometheus exposition format, these are shown as labels of either a custom metric (OpenMetrics) * or a [Gauge][io.prometheus.client.Gauge] (0.0.4 plain text). */ -class InfoMetric( +class InfoMetric @JvmOverloads constructor( /** the name of this metric */ override val name: String, /** the description of this metric */ @@ -32,13 +32,41 @@ class InfoMetric( /** the namespace (prefix) of this metric */ namespace: String, /** the value of this info metric */ - internal val value: String + internal val value: String = "", + /** Label names for this metric */ + val labelNames: List = emptyList() ) : Metric() { - private val info = Info.build(name, help).namespace(namespace).create().apply { info(name, value) } + private val info = run { + val builder = Info.build(name, help).namespace(namespace) + if (labelNames.isNotEmpty()) { + builder.labelNames(*labelNames.toTypedArray()) + } + builder.create().apply { + if (labelNames.isEmpty()) { + info(name, value) + } + } + } - override fun get() = value + override fun get() = if (labelNames.isEmpty()) value else throw UnsupportedOperationException() + fun get(labels: List = emptyList()) = + if (labels.isEmpty()) value else info.labels(*labels.toTypedArray()).get()[name] - override fun reset() = info.info(name, value) + override fun reset() = if (labelNames.isEmpty()) info.info(name, value) else info.clear() override fun register(registry: CollectorRegistry) = this.also { registry.register(info) } + + /** Remove the child with the given labels (the metric with those labels will stop being emitted) */ + fun remove(labels: List = emptyList()) = synchronized(info) { + if (labels.isNotEmpty()) { + info.remove(*labels.toTypedArray()) + } + } + + fun set(labels: List, value: String) { + if (labels.isNotEmpty()) { + info.labels(*labels.toTypedArray()).info(name, value) + } + } + internal fun collect() = info.collect() } diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt index 9d0912b4..c9511e10 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/MetricsContainer.kt @@ -238,7 +238,10 @@ open class MetricsContainer @JvmOverloads constructor( /** the description of the metric */ help: String, /** the value of the metric */ - value: String + value: String, + /** Label names for this metric. If non-empty, the initial value must be 0 and all get/update calls MUST + * specify values for the labels. Calls to simply get() or inc() will fail with an exception. */ + labelNames: List = emptyList() ): InfoMetric { if (metrics.containsKey(name)) { if (checkForNameConflicts) { @@ -246,7 +249,7 @@ open class MetricsContainer @JvmOverloads constructor( } return metrics[name] as InfoMetric } - return InfoMetric(name, help, namespace, value).apply { metrics[name] = register(registry) } + return InfoMetric(name, help, namespace, value, labelNames).apply { metrics[name] = register(registry) } } fun registerHistogram( diff --git a/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt b/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt index 4f6c05f2..8c0b99e7 100644 --- a/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt +++ b/jicoco-metrics/src/test/kotlin/org/jitsi/metrics/MetricTest.kt @@ -325,6 +325,34 @@ class MetricTest : ShouldSpec() { should("return the correct value") { get() shouldBe value } } } + context("With labels") { + with(InfoMetric("testInfo", "Help", namespace, labelNames = listOf("l1", "l2"))) { + val labels = listOf("A", "A") + val labels2 = listOf("A", "B") + val labels3 = listOf("B", "B") + + shouldThrow { get() } + + get(labels) shouldBe null + get(labels2) shouldBe null + + set(labels, "AA") + get(labels) shouldBe "AA" + get(labels2) shouldBe null + + set(labels3, "BB") + get(labels) shouldBe "AA" + get(labels3) shouldBe "BB" + + collect()[0].samples.size shouldBe 3 + remove(labels2) + // Down to two sets of labels + collect()[0].samples.size shouldBe 2 + get(labels) shouldBe "AA" + get(labels2) shouldBe null + get(labels3) shouldBe "BB" + } + } } context("HistogramMetric") { val namespace = "namespace" From 4f4649e927a0529b698a23473d75c6efc6b76e58 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Wed, 19 Feb 2025 16:28:27 -0600 Subject: [PATCH 6/6] fix: Add JvmOverloads to preserve the java API. --- .../src/main/kotlin/org/jitsi/metrics/BooleanMetric.kt | 2 ++ .../src/main/kotlin/org/jitsi/metrics/CounterMetric.kt | 4 ++++ .../src/main/kotlin/org/jitsi/metrics/DoubleGaugeMetric.kt | 5 +++++ .../src/main/kotlin/org/jitsi/metrics/LongGaugeMetric.kt | 6 ++++++ 4 files changed, 17 insertions(+) diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/BooleanMetric.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/BooleanMetric.kt index 42795ad5..6bb6357c 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/BooleanMetric.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/BooleanMetric.kt @@ -66,6 +66,7 @@ class BooleanMetric @JvmOverloads constructor( /** * Atomically sets the gauge to the given value. */ + @JvmOverloads fun set(newValue: Boolean, labels: List = emptyList()): Unit = synchronized(gauge) { if (labels.isEmpty()) { gauge.set(if (newValue) 1.0 else 0.0) @@ -79,6 +80,7 @@ class BooleanMetric @JvmOverloads constructor( * * @return the updated value */ + @JvmOverloads fun setAndGet(newValue: Boolean, labels: List = emptyList()): Boolean = synchronized(gauge) { set(newValue, labels) return newValue diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/CounterMetric.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/CounterMetric.kt index bd9d74c3..ecded387 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/CounterMetric.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/CounterMetric.kt @@ -76,6 +76,7 @@ class CounterMetric @JvmOverloads constructor( /** * Atomically adds the given value to this counter. */ + @JvmOverloads fun add(delta: Long, labels: List = emptyList()) = synchronized(counter) { if (labels.isEmpty()) { counter.inc(delta.toDouble()) @@ -89,6 +90,7 @@ class CounterMetric @JvmOverloads constructor( * * @return the updated value */ + @JvmOverloads fun addAndGet(delta: Long, labels: List = emptyList()): Long = synchronized(counter) { return if (labels.isEmpty()) { counter.inc(delta.toDouble()) @@ -104,11 +106,13 @@ class CounterMetric @JvmOverloads constructor( * * @return the updated value */ + @JvmOverloads fun incAndGet(labels: List = emptyList()) = addAndGet(1, labels) /** * Atomically increments the value of this counter by one. */ + @JvmOverloads fun inc(labels: List = emptyList()) = synchronized(counter) { if (labels.isEmpty()) { counter.inc() diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/DoubleGaugeMetric.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/DoubleGaugeMetric.kt index 16be1c99..ac94af3a 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/DoubleGaugeMetric.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/DoubleGaugeMetric.kt @@ -70,6 +70,7 @@ class DoubleGaugeMetric @JvmOverloads constructor( /** * Sets the value of this gauge to the given value. */ + @JvmOverloads fun set(newValue: Double, labels: List = emptyList()) { if (labels.isEmpty()) { gauge.set(newValue) @@ -83,6 +84,7 @@ class DoubleGaugeMetric @JvmOverloads constructor( * * @return the updated value */ + @JvmOverloads fun setAndGet(newValue: Double, labels: List = emptyList()): Double = synchronized(gauge) { return if (labels.isEmpty()) { gauge.set(newValue) @@ -100,6 +102,7 @@ class DoubleGaugeMetric @JvmOverloads constructor( * * @return the updated value */ + @JvmOverloads fun addAndGet(delta: Double, labels: List = emptyList()): Double = synchronized(gauge) { return if (labels.isEmpty()) { gauge.inc(delta) @@ -117,6 +120,7 @@ class DoubleGaugeMetric @JvmOverloads constructor( * * @return the updated value */ + @JvmOverloads fun incAndGet(labels: List = emptyList()) = addAndGet(1.0, labels) /** @@ -124,6 +128,7 @@ class DoubleGaugeMetric @JvmOverloads constructor( * * @return the updated value */ + @JvmOverloads fun decAndGet(labels: List = emptyList()) = addAndGet(-1.0, labels) /** Remove the child with the given labels (the metric with those labels will stop being emitted) */ diff --git a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/LongGaugeMetric.kt b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/LongGaugeMetric.kt index 42078320..f315ee15 100644 --- a/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/LongGaugeMetric.kt +++ b/jicoco-metrics/src/main/kotlin/org/jitsi/metrics/LongGaugeMetric.kt @@ -69,6 +69,7 @@ class LongGaugeMetric @JvmOverloads constructor( /** * Atomically sets the gauge to the given value. */ + @JvmOverloads fun set(newValue: Long, labels: List = emptyList()): Unit = synchronized(gauge) { if (labels.isEmpty()) { gauge.set(newValue.toDouble()) @@ -80,6 +81,7 @@ class LongGaugeMetric @JvmOverloads constructor( /** * Atomically increments the value of this gauge by one. */ + @JvmOverloads fun inc(labels: List = emptyList()) = synchronized(gauge) { if (labels.isEmpty()) { gauge.inc() @@ -91,6 +93,7 @@ class LongGaugeMetric @JvmOverloads constructor( /** * Atomically decrements the value of this gauge by one. */ + @JvmOverloads fun dec(labels: List = emptyList()) = synchronized(gauge) { if (labels.isEmpty()) { gauge.dec() @@ -104,6 +107,7 @@ class LongGaugeMetric @JvmOverloads constructor( * * @return the updated value */ + @JvmOverloads fun addAndGet(delta: Long, labels: List = emptyList()): Long = synchronized(gauge) { return if (labels.isEmpty()) { gauge.inc(delta.toDouble()) @@ -121,6 +125,7 @@ class LongGaugeMetric @JvmOverloads constructor( * * @return the updated value */ + @JvmOverloads fun incAndGet(labels: List = emptyList()) = addAndGet(1, labels) /** @@ -128,6 +133,7 @@ class LongGaugeMetric @JvmOverloads constructor( * * @return the updated value */ + @JvmOverloads fun decAndGet(labels: List = emptyList()) = addAndGet(-1, labels) /** Remove the child with the given labels (the metric with those labels will stop being emitted) */