Skip to content

Commit

Permalink
Add Prometheus export of data summary stats (#19742)
Browse files Browse the repository at this point in the history
* Add Prometheus export of data summary stats
  • Loading branch information
jason-p-pickering authored Jan 23, 2025
1 parent a009c2d commit 56764e3
Show file tree
Hide file tree
Showing 4 changed files with 390 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.webapi.controller;

import static org.junit.jupiter.api.Assertions.*;

import org.hisp.dhis.http.HttpClientAdapter;
import org.hisp.dhis.http.HttpStatus;
import org.hisp.dhis.jsontree.JsonMixed;
import org.hisp.dhis.jsontree.JsonValue;
import org.hisp.dhis.test.webapi.PostgresControllerIntegrationTestBase;
import org.junit.jupiter.api.Test;

class DataSummaryControllerTest extends PostgresControllerIntegrationTestBase {

@Test
void canGetPrometheusMetrics() {

HttpResponse response = GET("/api/dataSummary/metrics", HttpClientAdapter.Accept("text/plain"));
assertEquals(HttpStatus.OK, response.status());
String content = response.content("text/plain");
assertFalse(content.isEmpty(), "Response content should not be empty");
assertTrue(
content.contains("# HELP data_summary_active_users"), "Active users help text is missing");
assertTrue(
content.lines().anyMatch(line -> line.startsWith("data_summary_active_user")),
"Active users metric is missing");
assertTrue(
content.contains("# HELP data_summary_object_counts"),
"Object counts help text is missing");
assertTrue(
content.lines().anyMatch(line -> line.startsWith("data_summary_object_counts")),
"Object counts metric is missing");
assertTrue(
content.contains("# HELP data_summary_data_value_count"),
"Data value count help text is missing");
assertTrue(
content.lines().anyMatch(line -> line.startsWith("data_summary_data_value_count")),
"Data value count metric is missing");
assertTrue(
content.contains("# HELP data_summary_event_count"), "Event count help text is missing");
assertTrue(
content.lines().anyMatch(line -> line.startsWith("data_summary_event_count")),
"Event count metric is missing");
assertTrue(
content.contains("# HELP data_summary_build_info"), "Build info help text is missing");
// data_summary_build_info should end with an integer representing the build time in seconds
// since epoch
assertTrue(
content.lines().anyMatch(line -> line.matches("data_summary_build_info\\{.*\\} \\d+")),
"Build info metric should end with an integer");
assertTrue(content.contains("# HELP data_summary_system_id"), "System ID help text is missing");
// data_summary_system_id metric should be a static value of 1
assertTrue(
content.lines().anyMatch(line -> line.matches("data_summary_system_id\\{.*\\} 1")),
"System ID metric should be a static value of 1");
}

@Test
void canGetSummaryStatistics() {

HttpResponse response = GET("/api/dataSummary");
assertEquals(HttpStatus.OK, response.status());
JsonMixed content = response.content();
assertFalse(content.isEmpty(), "Response content should not be empty");
assertTrue(content.has("system"), "System information is missing");
assertTrue(content.has("objectCounts"), "Object counts are missing");
content
.get("objectCounts")
.asMap(JsonValue.class)
.values()
.forEach(value -> assertTrue(value.isInteger(), "Object count values should be integers"));
assertTrue(content.has("activeUsers"), "Active users are missing");
content
.get("activeUsers")
.asMap(JsonValue.class)
.values()
.forEach(value -> assertTrue(value.isInteger(), "Active user values should be integers"));
content
.get("activeUsers")
.asMap(JsonValue.class)
.keys()
.forEach(key -> assertTrue(key.matches("\\d{1,2}"), "Active user keys should be integers"));
assertTrue(content.has("userInvitations"), "User invitations are missing");
content
.get("activeUsers")
.asMap(JsonValue.class)
.values()
.forEach(
value -> assertTrue(value.isInteger(), "User invitation values should be integers"));
assertTrue(content.has("dataValueCount"), "Data value counts are missing");
content
.get("dataValueCount")
.asMap(JsonValue.class)
.values()
.forEach(
value -> assertTrue(value.isInteger(), "Data value count values should be integers"));
content
.get("dataValueCount")
.asMap(JsonValue.class)
.keys()
.forEach(
key -> assertTrue(key.matches("\\d{1,2}"), "Data value count keys should be integers"));
assertTrue(content.has("eventCount"), "Event counts are missing");
content
.get("eventCount")
.asMap(JsonValue.class)
.values()
.forEach(value -> assertTrue(value.isInteger(), "Event count values should be integers"));
content
.get("eventCount")
.asMap(JsonValue.class)
.keys()
.forEach(key -> assertTrue(key.matches("\\d{1,2}"), "Event count keys should be integers"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@

import static org.hisp.dhis.security.Authorities.F_PERFORM_MAINTENANCE;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import static org.springframework.http.MediaType.TEXT_PLAIN_VALUE;

import org.hisp.dhis.common.Dhis2Info;
import org.hisp.dhis.common.DhisApiVersion;
import org.hisp.dhis.common.OpenApi;
import org.hisp.dhis.datastatistics.DataStatisticsService;
import org.hisp.dhis.datasummary.DataSummary;
import org.hisp.dhis.security.RequiresAuthority;
import org.hisp.dhis.webapi.mvc.annotation.ApiVersion;
import org.hisp.dhis.webapi.utils.PrometheusTextBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
Expand All @@ -55,11 +58,88 @@
@ApiVersion({DhisApiVersion.DEFAULT, DhisApiVersion.ALL})
public class DataSummaryController {

@Autowired private DataStatisticsService dataStatisticsService;
private final DataStatisticsService dataStatisticsService;

@Autowired
public DataSummaryController(DataStatisticsService dataStatisticsService) {
this.dataStatisticsService = dataStatisticsService;
}

@GetMapping(produces = APPLICATION_JSON_VALUE)
@RequiresAuthority(anyOf = F_PERFORM_MAINTENANCE)
public @ResponseBody DataSummary getStatistics() {
return dataStatisticsService.getSystemStatisticsSummary();
}

/**
* Appends system information metrics to the Prometheus metrics.
*
* @param systemInfo the system information containing version, commit, revision, and system ID
*/
public void appendSystemInfoMetrics(PrometheusTextBuilder metrics, Dhis2Info systemInfo) {
String metricName = "data_summary_build_info";
if (systemInfo != null) {
metrics.helpLine(metricName, "Build information");
metrics.typeLine(metricName, "gauge");
long buildTime = 0L;
if (systemInfo.getBuildTime() != null) {
buildTime = systemInfo.getBuildTime().toInstant().getEpochSecond();
}
metrics.append(
String.format(
"%s{version=\"%s\", commit=\"%s\"} %s%n",
metricName, systemInfo.getVersion(), systemInfo.getRevision(), buildTime));

metrics.helpLine("data_summary_system_id", "System ID");
metrics.typeLine("data_summary_system_id", "gauge");
metrics.append(
String.format("data_summary_system_id{system_id=\"%s\"} 1%n", systemInfo.getSystemId()));
}
}

@GetMapping(value = "/metrics", produces = TEXT_PLAIN_VALUE)
@RequiresAuthority(anyOf = F_PERFORM_MAINTENANCE)
public @ResponseBody String getPrometheusMetrics() {
DataSummary summary = dataStatisticsService.getSystemStatisticsSummary();
final String PROMETHEUS_GAUGE_NAME = "gauge";
PrometheusTextBuilder metrics = new PrometheusTextBuilder();

metrics.updateMetricsFromMap(
summary.getObjectCounts(),
"data_summary_object_counts",
"type",
"Count of metadata objects",
PROMETHEUS_GAUGE_NAME);

metrics.updateMetricsFromMap(
summary.getActiveUsers(),
"data_summary_active_users",
"days",
"Count of active users by day",
PROMETHEUS_GAUGE_NAME);

metrics.updateMetricsFromMap(
summary.getUserInvitations(),
"data_summary_user_invitations",
"type",
"Count of user invitations",
PROMETHEUS_GAUGE_NAME);

metrics.updateMetricsFromMap(
summary.getDataValueCount(),
"data_summary_data_value_count",
"days",
"Count of updated data values by day",
PROMETHEUS_GAUGE_NAME);

metrics.updateMetricsFromMap(
summary.getEventCount(),
"data_summary_event_count",
"days",
"Count of updated events by day",
PROMETHEUS_GAUGE_NAME);

appendSystemInfoMetrics(metrics, summary.getSystem());
return metrics.getMetrics();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.webapi.utils;

import java.util.Map;

/**
* A simple utility class to build Prometheus text format metrics. The Prometheus text format is
* documented here: https://prometheus.io/docs/instrumenting/exposition_formats/
*
* @author Jason P. Pickering
*/
public class PrometheusTextBuilder {

private StringBuilder metrics = new StringBuilder();

public void helpLine(String metricName, String help) {
metrics.append(String.format("# HELP %s %s%n", metricName, help));
}

/**
* Appends a Prometheus metric type line to the metrics.
*
* @param metricName the name of the metric
* @param type the type of the metric (e.g., counter, gauge)
*/
public void typeLine(String metricName, String type) {
metrics.append(String.format("# TYPE %s %s%n", metricName, type));
}

/**
* Transform a Map<String, ?> into a Prometheus text format metric. Note that the key is assumed
* to be a string, and the value should be a number which is capable of being converted to a
* string.
*
* @param map the map containing the metrics data
* @param metricName the name of the metric
* @param keyName the name of the key in the metric
* @param help the help text for the metric
* @param type the type of the metric
*/
public void updateMetricsFromMap(
Map<?, ?> map, String metricName, String keyName, String help, String type) {
helpLine(metricName, help);
typeLine(metricName, type);
map.forEach(
(key, value) ->
metrics.append("%s{%s=\"%s\"} %s%n".formatted(metricName, keyName, key, value)));
}

/**
* Returns the Prometheus metrics as a string.
*
* @return the metrics in Prometheus text format
*/
public String getMetrics() {
return metrics.toString();
}

/**
* Appends a formatted string to the Prometheus metrics. This is not checked for correctness, so
* be sure you know what you are doing before using this method.
*
* @param format the formatted string to append
*/
public void append(String format) {
metrics.append(format);
}
}
Loading

0 comments on commit 56764e3

Please sign in to comment.