Repository: jenkinsci/prometheus-plugin (java)

Issue:
Gauge showing size of build history for a job (number of old builds)
### What feature do you want to see added?

PArticularly in jenkins instances with hundreds of jobs, it is sometimes easy to miss the expiration of old runs/artefacts.

Having a gauge for each job indicating the size of the job build history would offer an easy way to spot such situations.

Right now, one has to go to the jenkins jobs directory and count tthe number of sub-directories of the job/$job/builds directory with a shell script - a bit arcane when at the same time there's also prometheus metrics available for other things.

A possible extension would be to not just count the size of the history in number of builds, but also in disk space utilization.  Running/managing a jenkins instance with terrabytes of build artefacts from hundreds of build of hundreds of jobs would be much more convenient that way.

### Upstream changes

_No response_

Pull request description:
Adding NbBuildsGauge which collects the number of available build per job


Adding new base classes to make calculations of values more testable

Fixes #506 

### Changes proposed

- Future extensions should be done via classes in org/jenkinsci/plugins/prometheus/metrics
- 
### Checklist

- [x] Includes tests covering the new functionality?
- [x] Ready for review
- [x] Follows CONTRIBUTING rules

### Notify


Patch:
diff --git a/docs/metrics/index.md b/docs/metrics/index.md
index e70bf36..1ed79d4 100644
--- a/docs/metrics/index.md
+++ b/docs/metrics/index.md
@@ -61,6 +61,8 @@ will just return the last build. You can enable per build metrics in the configu
 | default_jenkins_builds_<buildname>_last_last_build_tests_skipped            | Number of skipped tests during the last build                                                           | gauge           |
 | default_jenkins_builds_<buildname>_last_build_tests_failing                 | Number of failing tests during the last build                                                           | gauge           |
 | default_jenkins_builds_<buildname>_last_stage_duration_milliseconds_summary | Summary of Jenkins build times by Job and Stage in the last build                                       | summary         |
+| default_jenkins_builds_available_builds_count                               | Gauge which indicates how many builds are available for the given job                                   | gauge           |
+| default_jenkins_build_discard_active                                        | Gauge which indicates if the build discard feature is active for the job.                               | gauge           |
 
 
 
diff --git a/src/main/java/org/jenkinsci/plugins/prometheus/JobCollector.java b/src/main/java/org/jenkinsci/plugins/prometheus/JobCollector.java
index a201887..1064bac 100644
--- a/src/main/java/org/jenkinsci/plugins/prometheus/JobCollector.java
+++ b/src/main/java/org/jenkinsci/plugins/prometheus/JobCollector.java
@@ -12,9 +12,12 @@ import io.prometheus.client.Gauge;
 import io.prometheus.client.Summary;
 import org.apache.commons.lang.StringUtils;
 import org.jenkinsci.plugins.prometheus.config.PrometheusConfiguration;
+import org.jenkinsci.plugins.prometheus.metrics.jobs.BuildDiscardGauge;
+import org.jenkinsci.plugins.prometheus.metrics.jobs.HealthScoreGauge;
 import org.jenkinsci.plugins.prometheus.util.ConfigurationUtils;
 import org.jenkinsci.plugins.prometheus.util.Jobs;
 import org.jenkinsci.plugins.prometheus.util.Runs;
+import org.jenkinsci.plugins.prometheus.metrics.jobs.NbBuildsGauge;
 import org.jenkinsci.plugins.workflow.job.WorkflowRun;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -35,7 +38,12 @@ public class JobCollector extends Collector {
     private Summary summary;
     private Counter jobSuccessCount;
     private Counter jobFailedCount;
-    private Gauge jobHealthScore;
+    private HealthScoreGauge jobHealthScoreGauge;
+
+    private NbBuildsGauge nbBuildsGauge;
+
+    private BuildDiscardGauge buildDiscardGauge;
+
 
     private static class BuildMetrics {
 
@@ -187,12 +195,11 @@ public class JobCollector extends Collector {
         // This metric uses "base" labels as it is just the health score reported
         // by the job object and the optional labels params and status don't make much
         // sense in this context.
-        jobHealthScore = Gauge.build()
-                .name(fullname + "_health_score")
-                .subsystem(subsystem).namespace(namespace)
-                .labelNames(labelBaseNameArray)
-                .help("Health score of a job")
-                .create();
+        jobHealthScoreGauge = new HealthScoreGauge(labelBaseNameArray, namespace, subsystem);
+
+        nbBuildsGauge = new NbBuildsGauge(labelBaseNameArray, namespace, subsystem);
+
+        buildDiscardGauge = new BuildDiscardGauge(labelBaseNameArray, namespace, subsystem);
 
         if (PrometheusConfiguration.get().isPerBuildMetrics()) {
             labelNameArray = Arrays.copyOf(labelNameArray, labelNameArray.length + 1);
@@ -225,7 +232,9 @@ public class JobCollector extends Collector {
         addSamples(samples, summary.collect(), "Adding [{}] samples from summary ({})");
         addSamples(samples, jobSuccessCount.collect(), "Adding [{}] samples from counter ({})");
         addSamples(samples, jobFailedCount.collect(), "Adding [{}] samples from counter ({})");
-        addSamples(samples, jobHealthScore.collect(), "Adding [{}] samples from gauge ({})");
+        addSamples(samples, jobHealthScoreGauge.collect(), "Adding [{}] samples from gauge ({})");
+        addSamples(samples, nbBuildsGauge.collect(), "Adding [{}] samples from gauge ({})");
+        addSamples(samples, buildDiscardGauge.collect(), "Adding [{}] samples from gauge ({})");
 
         addSamples(samples, lastBuildMetrics);
         if (PrometheusConfiguration.get().isPerBuildMetrics()) {
@@ -276,8 +285,9 @@ public class JobCollector extends Collector {
             return;
         }
 
-        int score = job.getBuildHealth().getScore();
-        jobHealthScore.labels(baseLabelValueArray).set(score);
+        nbBuildsGauge.calculateMetric(job, baseLabelValueArray);
+        jobHealthScoreGauge.calculateMetric(job, baseLabelValueArray);
+        buildDiscardGauge.calculateMetric(job, baseLabelValueArray);
 
         processRun(job, lastBuild, baseLabelValueArray, lastBuildMetrics);
 
diff --git a/src/main/java/org/jenkinsci/plugins/prometheus/metrics/BaseMetricCollector.java b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/BaseMetricCollector.java
new file mode 100644
index 0000000..075096e
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/BaseMetricCollector.java
@@ -0,0 +1,31 @@
+package org.jenkinsci.plugins.prometheus.metrics;
+
+import hudson.model.Item;
+import io.prometheus.client.Collector;
+
+import java.util.List;
+
+public abstract class BaseMetricCollector<T extends Item, I extends Collector> implements MetricCollector<T, I> {
+
+    protected final static String SEPARATOR = "_";
+
+    protected final String[] labelNames;
+    protected final String namespace;
+    protected final String subsystem;
+
+    protected I collector;
+
+    public BaseMetricCollector(String[] labelNames, String namespace, String subsystem) {
+        this.labelNames = labelNames;
+        this.namespace = namespace;
+        this.subsystem = subsystem;
+        collector = initCollector();
+    }
+
+    protected abstract I initCollector();
+
+    @Override
+    public List<Collector.MetricFamilySamples> collect() {
+        return collector.collect();
+    }
+}
diff --git a/src/main/java/org/jenkinsci/plugins/prometheus/metrics/MetricCollector.java b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/MetricCollector.java
new file mode 100644
index 0000000..9074eba
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/MetricCollector.java
@@ -0,0 +1,27 @@
+package org.jenkinsci.plugins.prometheus.metrics;
+
+import hudson.model.Item;
+import io.prometheus.client.Collector;
+import io.prometheus.client.Gauge;
+
+import java.util.List;
+
+/**
+ * Implementations of this interface shall be able to construct and calculate any subclass of
+ * {@link io.prometheus.client.SimpleCollector}
+ * @param <T> - any subclass of {@link io.prometheus.client.SimpleCollector}
+ */
+public interface MetricCollector<T extends Item, I extends Collector> {
+
+    /**
+     * This method contains the logic to calculate a metric value based on the given Jenkins object (e.g. Job, Run,...)
+     * @param jenkinsObject - Examples: {@link hudson.model.Job}, {@link hudson.model.Run}
+     * @param labelValues - The label values for the calculation
+     */
+    void calculateMetric(T jenkinsObject, String[] labelValues);
+
+    /**
+     * Calling this method basically calls I.collect()
+     */
+    List<I.MetricFamilySamples> collect();
+}
diff --git a/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/BaseJobMetricCollector.java b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/BaseJobMetricCollector.java
new file mode 100644
index 0000000..a669d06
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/BaseJobMetricCollector.java
@@ -0,0 +1,21 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+
+import hudson.model.Item;
+import io.prometheus.client.Collector;
+import org.jenkinsci.plugins.prometheus.metrics.BaseMetricCollector;
+
+abstract class BaseJobMetricCollector<T extends Item, I extends Collector> extends BaseMetricCollector<T, I> {
+
+    public BaseJobMetricCollector(String[] labelNames, String namespace, String subSystem) {
+        super(labelNames, namespace, subSystem);
+    }
+
+    protected String calculateName(String name) {
+        return getBaseName() + SEPARATOR + name;
+    }
+
+    private String getBaseName() {
+        return "builds";
+    }
+}
diff --git a/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/BuildDiscardGauge.java b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/BuildDiscardGauge.java
new file mode 100644
index 0000000..e27c128
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/BuildDiscardGauge.java
@@ -0,0 +1,30 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+import hudson.model.Job;
+import io.prometheus.client.Gauge;
+import jenkins.model.BuildDiscarder;
+
+public class BuildDiscardGauge extends BaseJobMetricCollector<Job, Gauge>  {
+
+    public BuildDiscardGauge(String[] labelNames, String namespace, String subSystem) {
+        super(labelNames, namespace, subSystem);
+    }
+
+    @Override
+    protected Gauge initCollector() {
+        return Gauge.build()
+                .name(calculateName("discard_active"))
+                .subsystem(subsystem)
+                .namespace(namespace)
+                .labelNames(labelNames)
+                .help("Indicates if the build discarder is active for the given job")
+                .create();
+    }
+
+    @Override
+    public void calculateMetric(Job jenkinsObject, String[] labelValues) {
+        BuildDiscarder buildDiscarder = jenkinsObject.getBuildDiscarder();
+        double status = buildDiscarder != null ? 1.0 : 0.0;
+        this.collector.labels(labelValues).set(status);
+    }
+}
diff --git a/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/HealthScoreGauge.java b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/HealthScoreGauge.java
new file mode 100644
index 0000000..effffd4
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/HealthScoreGauge.java
@@ -0,0 +1,29 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+import hudson.model.Job;
+import io.prometheus.client.Gauge;
+
+public class HealthScoreGauge extends BaseJobMetricCollector<Job, Gauge> {
+
+    public HealthScoreGauge(String[] labelNames, String namespace, String subSystem) {
+        super(labelNames, namespace, subSystem);
+    }
+
+    @Override
+    protected Gauge initCollector() {
+        return Gauge.build()
+                .name(calculateName("health_score"))
+                .subsystem(subsystem)
+                .namespace(namespace)
+                .labelNames(labelNames)
+                .help("Health score of a job")
+                .create();
+    }
+
+    @Override
+    public void calculateMetric(Job jenkinsObject, String[] labelValues) {
+        int score = jenkinsObject.getBuildHealth().getScore();
+        this.collector.labels(labelValues).set(score);
+    }
+
+}
diff --git a/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/NbBuildsGauge.java b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/NbBuildsGauge.java
new file mode 100644
index 0000000..002e93e
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/NbBuildsGauge.java
@@ -0,0 +1,28 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+import hudson.model.Job;
+import io.prometheus.client.Gauge;
+
+public class NbBuildsGauge extends BaseJobMetricCollector<Job, Gauge> {
+
+    public NbBuildsGauge(String[] labelNames, String namespace, String subsystem) {
+        super(labelNames, namespace, subsystem);
+    }
+
+    @Override
+    protected Gauge initCollector() {
+        return Gauge.build()
+                .name(calculateName("available_builds_count"))
+                .subsystem(subsystem)
+                .namespace(namespace)
+                .labelNames(labelNames)
+                .help("Number of builds available for this job")
+                .create();
+    }
+
+    @Override
+    public void calculateMetric(Job jenkinsObject, String[] labelValues) {
+        int nbBuilds = jenkinsObject.getBuildsAsMap().size();
+        this.collector.labels(labelValues).set(nbBuilds);
+    }
+}


Test patch:
diff --git a/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/BuildDiscardGaugeTest.java b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/BuildDiscardGaugeTest.java
new file mode 100644
index 0000000..8e72361
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/BuildDiscardGaugeTest.java
@@ -0,0 +1,47 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+import hudson.tasks.LogRotator;
+import io.prometheus.client.Collector;
+import org.junit.Test;
+
+import java.util.List;
+
+import static org.mockito.Mockito.when;
+
+public class BuildDiscardGaugeTest extends JobCollectorTest {
+
+    @Override
+    void testCollectResult() {
+        when(job.getBuildDiscarder()).thenReturn(null);
+
+        BuildDiscardGauge sut = new BuildDiscardGauge(new String[]{"jenkins_job", "repo"}, "default", "jenkins");
+
+        sut.calculateMetric(job, new String[]{"job1", "NA"});
+        List<Collector.MetricFamilySamples> collect = sut.collect();
+
+        validateListSize(collect, 1);
+
+        Collector.MetricFamilySamples samples = collect.get(0);
+        validateNames(samples, new String[]{"default_jenkins_builds_discard_active"});
+        validateSize(samples, 1);
+        validateValue(samples.samples.get(0), 0.0);
+
+    }
+
+    @Test
+    public void testBuildDiscarderActive() {
+        when(job.getBuildDiscarder()).thenReturn(new LogRotator(1,2,3,4));
+
+        BuildDiscardGauge sut = new BuildDiscardGauge(new String[]{"jenkins_job", "repo"}, "default", "jenkins");
+
+        sut.calculateMetric(job, new String[]{"job1", "NA"});
+        List<Collector.MetricFamilySamples> collect = sut.collect();
+
+        validateListSize(collect, 1);
+
+        Collector.MetricFamilySamples samples = collect.get(0);
+        validateNames(samples, new String[]{"default_jenkins_builds_discard_active"});
+        validateSize(samples, 1);
+        validateValue(samples.samples.get(0), 1.0);
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/CollectorTest.java b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/CollectorTest.java
new file mode 100644
index 0000000..8fe4361
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/CollectorTest.java
@@ -0,0 +1,26 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+import io.prometheus.client.Collector;
+import org.junit.jupiter.api.Assertions;
+
+import java.util.List;
+
+public abstract class CollectorTest {
+
+    public void validateNames(Collector.MetricFamilySamples samples, String[] expectedNames) {
+        Assertions.assertArrayEquals(expectedNames, samples.getNames());
+    }
+
+    public void validateSize(Collector.MetricFamilySamples samples, int expectedSize) {
+        Assertions.assertEquals(expectedSize, samples.samples.size());
+    }
+
+    public void validateValue(Collector.MetricFamilySamples.Sample sample, double expectedValue) {
+        Assertions.assertEquals(expectedValue, sample.value);
+    }
+
+    public void validateListSize(List<Collector.MetricFamilySamples> collect, int expectedSize) {
+        Assertions.assertEquals(expectedSize, collect.size());
+    }
+
+}
diff --git a/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/HealthScoreGaugeTest.java b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/HealthScoreGaugeTest.java
new file mode 100644
index 0000000..92a8182
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/HealthScoreGaugeTest.java
@@ -0,0 +1,33 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+import hudson.model.HealthReport;
+import io.prometheus.client.Collector;
+import org.junit.Test;
+
+import java.util.List;
+
+import static org.mockito.Mockito.when;
+
+public class HealthScoreGaugeTest extends JobCollectorTest {
+
+    @Override
+    @Test
+    public void testCollectResult() {
+
+        HealthReport healthReport = new HealthReport();
+        healthReport.setScore(44);
+        when(job.getBuildHealth()).thenReturn(healthReport);
+
+        HealthScoreGauge sut = new HealthScoreGauge(new String[]{"jenkins_job", "repo"}, "default", "jenkins");
+
+        sut.calculateMetric(job, new String[]{"job1", "NA"});
+        List<Collector.MetricFamilySamples> collect = sut.collect();
+
+        validateListSize(collect, 1);
+
+        Collector.MetricFamilySamples samples = collect.get(0);
+        validateNames(samples, new String[]{"default_jenkins_builds_health_score"});
+        validateSize(samples, 1);
+        validateValue(samples.samples.get(0), 44.0);
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/JobCollectorTest.java b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/JobCollectorTest.java
new file mode 100644
index 0000000..e74639a
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/JobCollectorTest.java
@@ -0,0 +1,17 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+
+import hudson.model.Job;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public abstract class JobCollectorTest extends CollectorTest {
+
+    @Mock
+    protected Job job;
+
+
+    abstract void testCollectResult();
+}
diff --git a/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/NbBuildsGaugeTest.java b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/NbBuildsGaugeTest.java
new file mode 100644
index 0000000..ad29ec2
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/NbBuildsGaugeTest.java
@@ -0,0 +1,39 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+import hudson.model.RunMap;
+import io.prometheus.client.Collector;
+import org.jenkinsci.plugins.workflow.job.WorkflowRun;
+import org.junit.Test;
+import org.mockito.Mock;
+
+import java.util.List;
+
+import static org.mockito.Mockito.when;
+
+
+public class NbBuildsGaugeTest extends JobCollectorTest {
+
+    @Mock
+    RunMap<WorkflowRun> runMap;
+
+    @Test
+    public void testCollectResult() {
+
+        when(runMap.size()).thenReturn(12);
+        when(job.getBuildsAsMap()).thenReturn(runMap);
+
+        NbBuildsGauge sut = new NbBuildsGauge(new String[]{"jenkins_job", "repo"}, "default", "jenkins");
+
+        sut.calculateMetric(job, new String[]{"job1", "NA"});
+        List<Collector.MetricFamilySamples> collect = sut.collect();
+
+        validateListSize(collect, 1);
+
+        Collector.MetricFamilySamples samples = collect.get(0);
+
+        validateNames(samples, new String[]{"default_jenkins_builds_available_builds_count"});
+        validateSize(samples, 1);
+        validateValue(samples.samples.get(0), 12.0);
+
+    }
+}
\ No newline at end of file