Skip to content

Commit 16d30e5

Browse files
authored
Support Prometheus target metrics (#560)
* Add support for Prometheus metrics * changelog * clippies? * Support explicit metrics selection * clippy: allow long function
1 parent 93eab49 commit 16d30e5

4 files changed

Lines changed: 229 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88
### Added
99
- Added target metrics support for Go expvars
10+
- Added target metrics support for Prometheus
1011

1112
### Fixed
1213
- Fixed throttle behavior for generators that run very quickly

src/target_metrics.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ use serde::Deserialize;
99
use crate::signals::Shutdown;
1010

1111
pub mod expvar;
12+
pub mod prometheus;
1213

1314
#[derive(Debug, Clone, Copy)]
1415
/// Errors produced by [`Server`]
1516
pub enum Error {
16-
/// See [`crate::target_metrics::expvar::Expvar`] for details.
17+
/// See [`crate::target_metrics::expvar::Error`] for details.
1718
Expvar(expvar::Error),
19+
/// See [`crate::target_metrics::prometheus::Error`] for details.
20+
Prometheus(prometheus::Error),
1821
}
1922

2023
#[derive(Debug, Deserialize, PartialEq, Eq)]
@@ -23,13 +26,17 @@ pub enum Error {
2326
pub enum Config {
2427
/// See [`crate::target_metrics::expvar::Config`] for details.
2528
Expvar(expvar::Config),
29+
/// See [`crate::target_metrics::prometheus::Config`] for details.
30+
Prometheus(prometheus::Config),
2631
}
2732

2833
/// The `target_metrics` server.
2934
#[derive(Debug)]
3035
pub enum Server {
3136
/// See [`crate::target_metrics::expvar::Expvar`] for details.
3237
Expvar(expvar::Expvar),
38+
/// See [`crate::target_metrics::prometheus::Prometheus`] for details.
39+
Prometheus(prometheus::Prometheus),
3340
}
3441

3542
impl Server {
@@ -42,6 +49,9 @@ impl Server {
4249
pub fn new(config: Config, shutdown: Shutdown) -> Self {
4350
match config {
4451
Config::Expvar(conf) => Self::Expvar(expvar::Expvar::new(conf, shutdown)),
52+
Config::Prometheus(conf) => {
53+
Self::Prometheus(prometheus::Prometheus::new(conf, shutdown))
54+
}
4555
}
4656
}
4757

@@ -61,6 +71,7 @@ impl Server {
6171
pub async fn run(self) -> Result<(), Error> {
6272
match self {
6373
Server::Expvar(inner) => inner.run().await.map_err(Error::Expvar),
74+
Server::Prometheus(inner) => inner.run().await.map_err(Error::Prometheus),
6475
}
6576
}
6677
}

src/target_metrics/expvar.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ impl Expvar {
4444
/// using Go's expvar format.
4545
///
4646
pub(crate) fn new(config: Config, shutdown: Shutdown) -> Self {
47-
info!("expvar created");
4847
Self { config, shutdown }
4948
}
5049

@@ -67,7 +66,7 @@ impl Expvar {
6766
loop {
6867
tokio::time::sleep(Duration::from_secs(1)).await;
6968

70-
let Ok(resp) = client.get(&self.config.uri).send().await else {
69+
let Ok(resp) = client.get(&self.config.uri).timeout(Duration::from_secs(1)).send().await else {
7170
info!("failed to get expvar uri");
7271
continue;
7372
};

src/target_metrics/prometheus.rs

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
//! Prometheus target metrics fetcher
2+
//!
3+
//! This module scrapes Prometheus/OpenMetrics formatted metrics from the target
4+
//! software.
5+
//!
6+
7+
use std::{collections::HashMap, str::FromStr, time::Duration};
8+
9+
use metrics::{counter, gauge};
10+
use serde::Deserialize;
11+
use tracing::{error, info, trace, warn};
12+
13+
use crate::signals::Shutdown;
14+
15+
#[derive(Debug, Clone, Copy, thiserror::Error)]
16+
/// Errors produced by [`Prometheus`]
17+
pub enum Error {
18+
/// Prometheus scraper shut down unexpectedly
19+
#[error("Unexpected shutdown")]
20+
EarlyShutdown,
21+
}
22+
23+
#[derive(Debug, Deserialize, PartialEq, Eq)]
24+
#[serde(rename_all = "snake_case")]
25+
/// Configuration for collecting Prometheus based target metrics
26+
pub struct Config {
27+
/// URI to scrape
28+
uri: String,
29+
/// Metric names to scrape. Leave unset to scrape all metrics.
30+
metrics: Option<Vec<String>>,
31+
}
32+
33+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34+
enum MetricType {
35+
Gauge,
36+
Counter,
37+
Histogram,
38+
Summary,
39+
}
40+
41+
#[derive(Debug)]
42+
enum MetricTypeParseError {
43+
UnknownType,
44+
}
45+
46+
impl FromStr for MetricType {
47+
type Err = MetricTypeParseError;
48+
49+
fn from_str(s: &str) -> Result<Self, Self::Err> {
50+
match s {
51+
"counter" => Ok(Self::Counter),
52+
"gauge" => Ok(Self::Gauge),
53+
"histogram" => Ok(Self::Histogram),
54+
"summary" => Ok(Self::Summary),
55+
_ => Err(MetricTypeParseError::UnknownType),
56+
}
57+
}
58+
}
59+
60+
/// The `Prometheus` target metrics implementation.
61+
#[derive(Debug)]
62+
pub struct Prometheus {
63+
config: Config,
64+
shutdown: Shutdown,
65+
}
66+
67+
impl Prometheus {
68+
/// Create a new [`Prometheus`] instance
69+
///
70+
/// This is responsible for scraping metrics from the target process in the
71+
/// Prometheus format.
72+
///
73+
pub(crate) fn new(config: Config, shutdown: Shutdown) -> Self {
74+
Self { config, shutdown }
75+
}
76+
77+
/// Run this [`Server`] to completion
78+
///
79+
/// Scrape metrics from the target at 1Hz.
80+
///
81+
/// # Errors
82+
///
83+
/// None are known.
84+
///
85+
/// # Panics
86+
///
87+
/// None are known.
88+
#[allow(clippy::cast_sign_loss)]
89+
#[allow(clippy::cast_possible_truncation)]
90+
#[allow(clippy::too_many_lines)]
91+
pub(crate) async fn run(mut self) -> Result<(), Error> {
92+
info!("Prometheus target metrics scraper running");
93+
let client = reqwest::Client::new();
94+
95+
let server = async move {
96+
loop {
97+
tokio::time::sleep(Duration::from_secs(1)).await;
98+
99+
let Ok(resp) = client.get(&self.config.uri).timeout(Duration::from_secs(1)).send().await else {
100+
info!("failed to get Prometheus uri");
101+
continue;
102+
};
103+
104+
let Ok(text) = resp.text().await else {
105+
info!("failed to read Prometheus response");
106+
continue;
107+
};
108+
109+
// remember the type for each metric across lines
110+
let mut typemap = HashMap::new();
111+
112+
// this deserves a real parser, but this will do for now.
113+
// Format doc: https://github.com/prometheus/docs/blob/main/content/docs/instrumenting/exposition_formats.md
114+
for line in text.lines() {
115+
if line.starts_with("# HELP") {
116+
continue;
117+
}
118+
119+
if line.starts_with("# TYPE") {
120+
let mut parts = line.split_ascii_whitespace().skip(2);
121+
let name = parts.next().unwrap();
122+
let metric_type = parts.next().unwrap();
123+
let metric_type: MetricType = metric_type.parse().unwrap();
124+
// summary and histogram metrics additionally report names suffixed with _sum, _count, _bucket
125+
if matches!(metric_type, MetricType::Histogram | MetricType::Summary) {
126+
typemap.insert(format!("{name}_sum"), metric_type);
127+
typemap.insert(format!("{name}_count"), metric_type);
128+
typemap.insert(format!("{name}_bucket"), metric_type);
129+
}
130+
typemap.insert(name.to_owned(), metric_type);
131+
continue;
132+
}
133+
134+
let mut parts = line.split_ascii_whitespace();
135+
let name_and_labels = parts.next().unwrap();
136+
let value = parts.next().unwrap();
137+
138+
let (name, labels) = {
139+
if let Some((name, labels)) = name_and_labels.split_once('{') {
140+
let labels = labels.trim_end_matches('}');
141+
let labels = labels.split(',').map(|label| {
142+
let (label_name, label_value) = label.split_once('=').unwrap();
143+
let label_value = label_value.trim_matches('\"');
144+
(label_name.to_owned(), label_value.to_owned())
145+
});
146+
let labels = labels.collect::<Vec<_>>();
147+
(name, Some(labels))
148+
} else {
149+
(name_and_labels, None)
150+
}
151+
};
152+
153+
let metric_type = typemap.get(name);
154+
let name = name.replace("__", ".");
155+
156+
if let Some(metrics) = &self.config.metrics {
157+
if !metrics.contains(&name) {
158+
continue;
159+
}
160+
}
161+
162+
match metric_type {
163+
Some(MetricType::Gauge) => {
164+
let Ok(value): Result<f64, _> = value.parse() else {
165+
let e = value.parse::<f64>().unwrap_err();
166+
warn!("{e}: {name} = {value}");
167+
continue;
168+
};
169+
170+
trace!("gauge: {name} = {value}");
171+
gauge!(format!("target/{name}"), value, &labels.unwrap_or_default());
172+
}
173+
Some(MetricType::Counter) => {
174+
let Ok(value): Result<f64, _> = value.parse() else {
175+
let e = value.parse::<f64>().unwrap_err();
176+
warn!("{e}: {name} = {value}");
177+
continue;
178+
};
179+
180+
let value = if value < 0.0 {
181+
warn!("Negative counter value unhandled");
182+
continue;
183+
} else {
184+
// clippy shows "error: casting `f64` to `u64` may lose the sign of the value". This is
185+
// guarded by the sign check above.
186+
if value > u64::MAX as f64 {
187+
warn!("Counter value above maximum limit");
188+
continue;
189+
}
190+
value as u64
191+
};
192+
193+
trace!("counter: {name} = {value}");
194+
counter!(format!("target/{name}"), value, &labels.unwrap_or_default());
195+
}
196+
Some(_) | None => {
197+
trace!("unsupported metric type: {name} = {value}");
198+
}
199+
}
200+
}
201+
}
202+
};
203+
204+
tokio::select! {
205+
_res = server => {
206+
error!("server shutdown unexpectedly");
207+
Err(Error::EarlyShutdown)
208+
}
209+
_ = self.shutdown.recv() => {
210+
info!("shutdown signal received");
211+
Ok(())
212+
}
213+
}
214+
}
215+
}

0 commit comments

Comments
 (0)