Repository: jenkinsci/prometheus-plugin (java)

Issue:
Metric to show duration of running build
### What feature do you want to see added?

We are looking for a way to create a Prometheus alert by writing an expression using metrics exposed under https://github.com/jenkinsci/prometheus-plugin/blob/master/docs/metrics/index.md. 

This Prometheus alert would report if there is any currently running job(s) that has been running for a very long time. 
for eg., for our case, it would be a job running for more than 5 hours.

Currently we have `default_jenkins_builds_last_build_duration_milliseconds` metric, which we looked at using, however that would also report jobs that are not currently running.

So would like to have a separate metric to report something like `default_jenkins_builds_running_build_duration_milliseconds` which would only be for jobs currently running at the moment.

### Upstream changes

_No response_

Pull request:
Adding metric which indicates the runtime of the current build. Can be used to monitor long running jobs

Fixes #505 

### Changes proposed

- Adding metric which monitors the runtime of a currently building job run

### Checklist

- [x] Includes tests covering the new functionality?
- [x] Ready for review
- [x] Follows CONTRIBUTING rules



Patch:
diff --git a/docs/metrics/index.md b/docs/metrics/index.md
index 1ed79d4..02a6244 100644
--- a/docs/metrics/index.md
+++ b/docs/metrics/index.md
@@ -62,7 +62,8 @@ will just return the last build. You can enable per build metrics in the configu
 | 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           |
+| default_jenkins_builds_discard_active                                       | Gauge which indicates if the build discard feature is active for the job.                               | gauge           |
+| default_jenkins_builds_running_build_duration_milliseconds                  | Gauge which indicates the runtime of the current build.                                                 | gauge           |
 
 
 
diff --git a/src/main/java/org/jenkinsci/plugins/prometheus/JobCollector.java b/src/main/java/org/jenkinsci/plugins/prometheus/JobCollector.java
index 1064bac..a26d0c1 100644
--- a/src/main/java/org/jenkinsci/plugins/prometheus/JobCollector.java
+++ b/src/main/java/org/jenkinsci/plugins/prometheus/JobCollector.java
@@ -13,7 +13,9 @@ 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.CurrentRunDurationGauge;
 import org.jenkinsci.plugins.prometheus.metrics.jobs.HealthScoreGauge;
+import org.jenkinsci.plugins.prometheus.util.ArrayUtils;
 import org.jenkinsci.plugins.prometheus.util.ConfigurationUtils;
 import org.jenkinsci.plugins.prometheus.util.Jobs;
 import org.jenkinsci.plugins.prometheus.util.Runs;
@@ -56,6 +58,8 @@ public class JobCollector extends Collector {
         public Gauge jobBuildTestsSkipped;
         public Gauge jobBuildTestsFailing;
 
+        public CurrentRunDurationGauge currentRunDurationGauge;
+
         private String buildPrefix;
 
         public BuildMetrics(String buildPrefix) {
@@ -117,6 +121,8 @@ public class JobCollector extends Collector {
                     .labelNames(labelStageNameArray)
                     .help("Summary of Jenkins build times by Job and Stage in the last build")
                     .create();
+
+            this.currentRunDurationGauge = new CurrentRunDurationGauge(labelNameArray, namespace, subsystem);
         }
     }
 
@@ -258,6 +264,7 @@ public class JobCollector extends Collector {
         addSamples(allSamples, buildMetrics.jobBuildResultOrdinal.collect(), "Adding [{}] samples from gauge ({})");
         addSamples(allSamples, buildMetrics.jobBuildResult.collect(), "Adding [{}] samples from gauge ({})");
         addSamples(allSamples, buildMetrics.jobBuildDuration.collect(), "Adding [{}] samples from gauge ({})");
+        addSamples(allSamples, buildMetrics.currentRunDurationGauge.collect(), "Adding [{}] samples from gauge ({})");
         addSamples(allSamples, buildMetrics.jobBuildStartMillis.collect(), "Adding [{}] samples from gauge ({})");
         addSamples(allSamples, buildMetrics.jobBuildTestsTotal.collect(), "Adding [{}] samples from gauge ({})");
         addSamples(allSamples, buildMetrics.jobBuildTestsSkipped.collect(), "Adding [{}] samples from gauge ({})");
@@ -373,8 +380,9 @@ public class JobCollector extends Collector {
         logger.debug("Processing run [{}] from job [{}]", run.getNumber(), job.getName());
 
         buildMetrics.jobBuildStartMillis.labels(buildLabelValueArray).set(millis);
-        if (!run.isBuilding()) {
 
+        buildMetrics.currentRunDurationGauge.calculateMetric(job, buildLabelValueArray);
+        if (!run.isBuilding()) {
             buildMetrics.jobBuildDuration.labels(buildLabelValueArray).set(duration);
             processRunTestsResults(run, buildLabelValueArray, buildMetrics);
 
diff --git a/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/CurrentRunDurationGauge.java b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/CurrentRunDurationGauge.java
new file mode 100644
index 0000000..cf56a7c
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/prometheus/metrics/jobs/CurrentRunDurationGauge.java
@@ -0,0 +1,46 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+import hudson.model.Job;
+import hudson.model.Run;
+import io.prometheus.client.Gauge;
+import org.jenkinsci.plugins.prometheus.util.ArrayUtils;
+
+import java.time.Clock;
+
+public class CurrentRunDurationGauge extends BaseJobMetricCollector<Job, Gauge> {
+
+    public CurrentRunDurationGauge(String[] labelNames, String namespace, String subSystem) {
+        super(labelNames, namespace, subSystem);
+    }
+
+    @Override
+    protected Gauge initCollector() {
+        return Gauge.build()
+                .name(calculateName("running_build_duration_milliseconds"))
+                .subsystem(subsystem)
+                .namespace(namespace)
+                .labelNames(ArrayUtils.appendToArray(labelNames, "building"))
+                .help("Indicates the runtime of the run currently building if there is a run currently building")
+                .create();
+    }
+
+    @Override
+    public void calculateMetric(Job jenkinsObject, String[] labelValues) {
+        boolean isBuilding = jenkinsObject.isBuilding();
+
+        String[] labels = ArrayUtils.appendToArray(labelValues, String.valueOf(isBuilding));
+        if (isBuilding) {
+            Run runningBuild = jenkinsObject.getLastBuild();
+            if (runningBuild != null) {
+                long start = runningBuild.getStartTimeInMillis();
+                // Using Clock to be able to mock in test
+                long end = Clock.systemUTC().millis();
+                long duration = Math.max(end - start, 0);
+                this.collector.labels(labels).set(duration);
+                return;
+            }
+        }
+
+        this.collector.labels(labels).set(0.0);
+    }
+}
diff --git a/src/main/java/org/jenkinsci/plugins/prometheus/util/ArrayUtils.java b/src/main/java/org/jenkinsci/plugins/prometheus/util/ArrayUtils.java
new file mode 100644
index 0000000..e0264c7
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/prometheus/util/ArrayUtils.java
@@ -0,0 +1,17 @@
+package org.jenkinsci.plugins.prometheus.util;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class ArrayUtils {
+
+    public static String[] appendToArray(String[] array, String newValue) {
+        if (array == null) {
+            return null;
+        }
+        List<String> tempList = new ArrayList<>(Arrays.asList(array));
+        tempList.add(newValue);
+        return tempList.toArray(new String[0]);
+    }
+}


Test patch:
diff --git a/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/CurrentRunDurationGaugeTest.java b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/CurrentRunDurationGaugeTest.java
new file mode 100644
index 0000000..84540ab
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/prometheus/metrics/jobs/CurrentRunDurationGaugeTest.java
@@ -0,0 +1,96 @@
+package org.jenkinsci.plugins.prometheus.metrics.jobs;
+
+import hudson.model.Run;
+import io.prometheus.client.Collector;
+import org.junit.Test;
+import org.junit.jupiter.api.Assertions;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.time.Clock;
+import java.util.List;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class CurrentRunDurationGaugeTest extends JobCollectorTest {
+
+    @Mock
+    Run currentRun;
+
+    @Override
+    @Test
+    public void testCollectResult() {
+        when(job.isBuilding()).thenReturn(true);
+        when(currentRun.getStartTimeInMillis()).thenReturn(1000L);
+        when(job.getLastBuild()).thenReturn(currentRun);
+
+        CurrentRunDurationGauge sut = new CurrentRunDurationGauge(new String[]{"jenkins_job", "repo"}, "default", "jenkins");
+
+        try (MockedStatic<Clock> mock = Mockito.mockStatic(Clock.class)) {
+
+            Clock mockedClock = mock(Clock.class);
+            mock.when(Clock::systemUTC).thenReturn(mockedClock);
+            when(mockedClock.millis()).thenReturn(2000L);
+
+            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_running_build_duration_milliseconds"});
+            validateSize(samples, 1);
+            validateValue(samples.samples.get(0), 1000.0);
+        }
+    }
+
+    @Test
+    public void testCurrentlyRunningLabelValue() {
+        when(job.isBuilding()).thenReturn(true);
+        when(currentRun.getStartTimeInMillis()).thenReturn(1000L);
+        when(job.getLastBuild()).thenReturn(currentRun);
+
+        CurrentRunDurationGauge sut = new CurrentRunDurationGauge(new String[]{"jenkins_job", "repo"}, "default", "jenkins");
+
+        sut.calculateMetric(job, new String[]{"job1", "NA"});
+
+        List<String> labelNames = sut.collect().get(0).samples.get(0).labelNames;
+        List<String> labelValues = sut.collect().get(0).samples.get(0).labelValues;
+
+        Assertions.assertEquals(3, labelNames.size());
+        Assertions.assertEquals(3, labelValues.size());
+
+        Assertions.assertEquals("building", labelNames.get(2));
+        Assertions.assertEquals("true", labelValues.get(2));
+
+        double value = sut.collect().get(0).samples.get(0).value;
+
+        Assertions.assertNotEquals(0.0, value);
+    }
+
+    @Test
+    public void testCurrentlyNotRunningLabelValue() {
+        when(job.isBuilding()).thenReturn(false);
+
+        CurrentRunDurationGauge sut = new CurrentRunDurationGauge(new String[]{"jenkins_job", "repo"}, "default", "jenkins");
+
+        sut.calculateMetric(job, new String[]{"job1", "NA"});
+
+        List<String> labelNames = sut.collect().get(0).samples.get(0).labelNames;
+        List<String> labelValues = sut.collect().get(0).samples.get(0).labelValues;
+
+        Assertions.assertEquals(3, labelNames.size());
+        Assertions.assertEquals(3, labelValues.size());
+
+        Assertions.assertEquals("building", labelNames.get(2));
+        Assertions.assertEquals("false", labelValues.get(2));
+
+        double value = sut.collect().get(0).samples.get(0).value;
+
+        Assertions.assertEquals(0.0, value);
+    }
+
+
+}
\ No newline at end of file
diff --git a/src/test/java/org/jenkinsci/plugins/prometheus/util/ArrayUtilsTest.java b/src/test/java/org/jenkinsci/plugins/prometheus/util/ArrayUtilsTest.java
new file mode 100644
index 0000000..8e8ecc5
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/prometheus/util/ArrayUtilsTest.java
@@ -0,0 +1,17 @@
+package org.jenkinsci.plugins.prometheus.util;
+
+import org.junit.Test;
+import org.junit.jupiter.api.Assertions;
+
+public class ArrayUtilsTest {
+
+    @Test
+    public void testElementAdded() {
+        String[] array = new String[]{"one", "two"};
+
+        String[] newArray = ArrayUtils.appendToArray(array, "three");
+
+        Assertions.assertEquals(3, newArray.length);
+        Assertions.assertEquals("three", newArray[2]);
+    }
+}
\ No newline at end of file