Skip to content

Commit 19b2cea

Browse files
steffen-vogel-opalrtSystemsPurge
authored andcommitted
feat(api): Add a new metrics endpoint for exporting statistics to Prometheus
Co-authored-by: SystemsPurge <naktiyoussef@proton.me> Signed-off-by: SystemsPurge <naktiyoussef@proton.me> Co-authored-by: Steffen Vogel <steffen.vogel@opal-rt.com> Signed-off-by: Steffen Vogel <steffen.vogel@opal-rt.com>
1 parent c9ba515 commit 19b2cea

5 files changed

Lines changed: 273 additions & 7 deletions

File tree

common/include/villas/hist.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#pragma once
99

10+
#include <string>
1011
#include <vector>
1112

1213
#include <jansson.h>
@@ -60,6 +61,9 @@ class Hist {
6061
// Write the histogram in JSON format to file \p f.
6162
int dumpJson(FILE *f) const;
6263

64+
std::string toPrometheusText(const std::string &metric_name,
65+
const std::string &node_name) const;
66+
6367
// Build a libjansson / JSON object of the histogram.
6468
json_t *toJson() const;
6569

common/lib/hist.cpp

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,16 @@ void Hist::put(double value) {
3737
if (data.size()) {
3838
if (total < warmup) {
3939
// We are still in warmup phase... Waiting for more samples...
40-
} else if (data.size() && total == warmup && warmup != 0) {
41-
low = getMean() - 3 * getStddev();
42-
high = getMean() + 3 * getStddev();
40+
} else if (total == warmup) {
41+
if (warmup != 0) {
42+
low = getMean() - 3 * getStddev();
43+
high = getMean() + 3 * getStddev();
44+
} else {
45+
low = -10;
46+
high = 10;
47+
}
48+
4349
resolution = (high - low) / data.size();
44-
} else if (data.size() && (total == warmup) && (warmup == 0)) {
45-
// There is no warmup phase
46-
// TODO resolution = ?
4750
} else {
4851
idx_t idx = std::round((value - low) / resolution);
4952

@@ -136,7 +139,7 @@ void Hist::plot(Logger logger) const {
136139
table.header();
137140

138141
for (size_t i = 0; i < data.size(); i++) {
139-
double value = low + (i)*resolution;
142+
double value = low + (double)i * resolution;
140143
Hist::cnt_t cnt = data[i];
141144
int bar = cols[2].getWidth() * ((double)cnt / max);
142145

@@ -150,6 +153,36 @@ void Hist::plot(Logger logger) const {
150153
}
151154
}
152155

156+
std::string Hist::toPrometheusText(const std::string &metric_name,
157+
const std::string &node_name) const {
158+
std::stringstream base;
159+
base << "#TYPE HISTOGRAM " << metric_name;
160+
161+
// Needed because Prometheus understands quantiles.
162+
cnt_t cumsum = 0;
163+
for (size_t i = 0; i < data.size(); i++) {
164+
double value = low + ((double)i + 0.5) * resolution;
165+
166+
if (cumsum <= UINTMAX_MAX - data[i]) {
167+
cumsum += data[i];
168+
} else {
169+
cumsum = UINTMAX_MAX; // Avoid overflow
170+
}
171+
172+
base << "\n"
173+
<< metric_name << " {node=\"" << node_name << "\" le=\"" << value
174+
<< "\"} " << cumsum;
175+
}
176+
177+
base << "\n"
178+
<< metric_name << " {node=\"" << node_name << "\" le=\"+Inf\"} " << total
179+
<< "\n"
180+
<< metric_name << "_count "
181+
<< " {node=\"" << node_name << "\"} " << total;
182+
183+
return base.str();
184+
}
185+
153186
char *Hist::dump() const {
154187
char *buf = new char[128];
155188
if (!buf)

lib/api/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ set(API_SRC
2626
requests/node_stats.cpp
2727
requests/node_stats_reset.cpp
2828
requests/node_file.cpp
29+
requests/metrics.cpp
2930
requests/paths.cpp
3031
requests/path_info.cpp
3132
requests/path_action.cpp

lib/api/requests/metrics.cpp

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/* The metrics API ressource.
2+
*
3+
* Author: Steffen Vogel <post@steffenvogel.de>
4+
* Author: Youssef Nakti <naktiyoussef@proton.me>
5+
* SPDX-FileCopyrightText: 2014-2023 Institute for Automation of Complex Power Systems, RWTH Aachen University
6+
* SPDX-License-Identifier: Apache-2.0
7+
*/
8+
9+
#include <chrono>
10+
11+
#include <jansson.h>
12+
13+
#include <villas/api/request.hpp>
14+
#include <villas/api/response.hpp>
15+
#include <villas/api/session.hpp>
16+
#include <villas/node.hpp>
17+
#include <villas/stats.hpp>
18+
#include <villas/super_node.hpp>
19+
#include <villas/utils.hpp>
20+
21+
namespace villas {
22+
namespace node {
23+
namespace api {
24+
25+
class MetricsRequest : public Request {
26+
public:
27+
using Request::Request;
28+
29+
virtual Response *execute() {
30+
if (method != Session::Method::GET) {
31+
throw InvalidMethod(this);
32+
}
33+
34+
if (body != nullptr) {
35+
throw BadRequest("The metrics endpoint does not accept any body data");
36+
}
37+
38+
std::stringstream ss;
39+
NodeList node_list = session->getSuperNode()->getNodes();
40+
41+
for (Node *node : node_list) {
42+
auto stats = node->getStats();
43+
if (!stats)
44+
continue;
45+
46+
std::string node_name = node->getNameShort();
47+
for (auto &metric : Stats::metrics) {
48+
std::string metric_name = metric.second.name;
49+
std::replace(metric_name.begin(), metric_name.end(), '.', '_');
50+
ss << stats->getHistogram(metric.first)
51+
.toPrometheusText(metric_name, node->getNameShort())
52+
<< "\n\n";
53+
}
54+
}
55+
56+
auto str = ss.str();
57+
return new Response(session, HTTP_STATUS_OK, "text/plain; charset=UTF-8",
58+
Buffer(str.c_str(), str.size()));
59+
}
60+
};
61+
62+
// Register API request
63+
static char n[] = "metrics";
64+
static char r[] = "/metrics";
65+
static char d[] = "Get stats of all nodes in Prometheus metrics format";
66+
static RequestPlugin<MetricsRequest, n, r, d> p;
67+
68+
} // namespace api
69+
} // namespace node
70+
} // namespace villas

tests/integration/api-metrics.sh

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Integration test for remote API
4+
#
5+
# Author: Steffen Vogel <steffen.vogel@opal-rt.com>
6+
# SPDX-FileCopyrightText: 2025 OPAL-RT Germany GmbH
7+
# SPDX-License-Identifier: Apache-2.0
8+
9+
set -e
10+
11+
DIR=$(mktemp -d)
12+
pushd ${DIR}
13+
14+
function finish {
15+
popd
16+
rm -rf ${DIR}
17+
}
18+
trap finish EXIT
19+
20+
cat > config.json <<EOF
21+
{
22+
"http": {
23+
"port": 8080
24+
},
25+
"nodes": {
26+
"node1": {
27+
"type": "signal",
28+
29+
"signal": "sine",
30+
"limit": 10,
31+
"values": 1,
32+
33+
"in": {
34+
"hooks": [
35+
{
36+
"type": "stats",
37+
"warmup": 0,
38+
"buckets": 5
39+
}
40+
]
41+
}
42+
}
43+
},
44+
"paths": [
45+
{
46+
"in": "node1"
47+
}
48+
]
49+
}
50+
EOF
51+
52+
cat <<EOF > expected
53+
#TYPE HISTOGRAM rtp_jitter
54+
rtp_jitter {node="node1" le="0"} 0
55+
rtp_jitter {node="node1" le="0"} 0
56+
rtp_jitter {node="node1" le="0"} 0
57+
rtp_jitter {node="node1" le="0"} 0
58+
rtp_jitter {node="node1" le="0"} 0
59+
rtp_jitter {node="node1" le="+Inf"} 0
60+
rtp_jitter_count {node="node1"} 0
61+
62+
#TYPE HISTOGRAM rtp_pkts_lost
63+
rtp_pkts_lost {node="node1" le="0"} 0
64+
rtp_pkts_lost {node="node1" le="0"} 0
65+
rtp_pkts_lost {node="node1" le="0"} 0
66+
rtp_pkts_lost {node="node1" le="0"} 0
67+
rtp_pkts_lost {node="node1" le="0"} 0
68+
rtp_pkts_lost {node="node1" le="+Inf"} 0
69+
rtp_pkts_lost_count {node="node1"} 0
70+
71+
#TYPE HISTOGRAM rtp_loss_fraction
72+
rtp_loss_fraction {node="node1" le="0"} 0
73+
rtp_loss_fraction {node="node1" le="0"} 0
74+
rtp_loss_fraction {node="node1" le="0"} 0
75+
rtp_loss_fraction {node="node1" le="0"} 0
76+
rtp_loss_fraction {node="node1" le="0"} 0
77+
rtp_loss_fraction {node="node1" le="+Inf"} 0
78+
rtp_loss_fraction_count {node="node1"} 0
79+
80+
#TYPE HISTOGRAM signal_cnt
81+
signal_cnt {node="node1" le="-8"} 0
82+
signal_cnt {node="node1" le="-4"} 0
83+
signal_cnt {node="node1" le="0"} 0
84+
signal_cnt {node="node1" le="4"} 9
85+
signal_cnt {node="node1" le="8"} 9
86+
signal_cnt {node="node1" le="+Inf"} 10
87+
signal_cnt_count {node="node1"} 10
88+
89+
#TYPE HISTOGRAM age
90+
age {node="node1" le="0"} 0
91+
age {node="node1" le="0"} 0
92+
age {node="node1" le="0"} 0
93+
age {node="node1" le="0"} 0
94+
age {node="node1" le="0"} 0
95+
age {node="node1" le="+Inf"} 0
96+
age_count {node="node1"} 0
97+
98+
#TYPE HISTOGRAM owd
99+
owd {node="node1" le="-8"} 0
100+
owd {node="node1" le="-4"} 0
101+
owd {node="node1" le="0"} 0
102+
owd {node="node1" le="4"} 8
103+
owd {node="node1" le="8"} 8
104+
owd {node="node1" le="+Inf"} 9
105+
owd_count {node="node1"} 9
106+
107+
#TYPE HISTOGRAM gap_received
108+
gap_received {node="node1" le="-8"} 0
109+
gap_received {node="node1" le="-4"} 0
110+
gap_received {node="node1" le="0"} 0
111+
gap_received {node="node1" le="4"} 8
112+
gap_received {node="node1" le="8"} 8
113+
gap_received {node="node1" le="+Inf"} 9
114+
gap_received_count {node="node1"} 9
115+
116+
#TYPE HISTOGRAM gap_sent
117+
gap_sent {node="node1" le="-8"} 0
118+
gap_sent {node="node1" le="-4"} 0
119+
gap_sent {node="node1" le="0"} 0
120+
gap_sent {node="node1" le="4"} 8
121+
gap_sent {node="node1" le="8"} 8
122+
gap_sent {node="node1" le="+Inf"} 9
123+
gap_sent_count {node="node1"} 9
124+
125+
#TYPE HISTOGRAM reordered
126+
reordered {node="node1" le="0"} 0
127+
reordered {node="node1" le="0"} 0
128+
reordered {node="node1" le="0"} 0
129+
reordered {node="node1" le="0"} 0
130+
reordered {node="node1" le="0"} 0
131+
reordered {node="node1" le="+Inf"} 0
132+
reordered_count {node="node1"} 0
133+
134+
#TYPE HISTOGRAM skipped
135+
skipped {node="node1" le="0"} 0
136+
skipped {node="node1" le="0"} 0
137+
skipped {node="node1" le="0"} 0
138+
skipped {node="node1" le="0"} 0
139+
skipped {node="node1" le="0"} 0
140+
skipped {node="node1" le="+Inf"} 0
141+
skipped_count {node="node1"} 0
142+
143+
EOF
144+
145+
# Start VILLASnode instance with local config
146+
villas node config.json &
147+
148+
# Wait for node to complete init
149+
sleep 2
150+
151+
# Fetch config via API
152+
curl -s http://localhost:8080/api/v2/metrics > metrics
153+
154+
# Shutdown VILLASnode
155+
kill $!
156+
157+
# Check if metrics contain expected values
158+
diff -u expected metrics

0 commit comments

Comments
 (0)