use super::{Metric, MetricExporter, MetricValue};
use crate::error::Result;
use parking_lot::RwLock;
use std::sync::Arc;
pub struct PrometheusExporter {
endpoint: String,
#[cfg(feature = "http-exporter")]
client: reqwest::blocking::Client,
buffer: Arc<RwLock<Vec<Metric>>>,
}
impl PrometheusExporter {
pub fn new(endpoint: String) -> Self {
Self {
endpoint,
#[cfg(feature = "http-exporter")]
client: reqwest::blocking::Client::new(),
buffer: Arc::new(RwLock::new(Vec::new())),
}
}
fn format_prometheus_metric(&self, metric: &Metric) -> String {
let labels_str = if metric.labels.is_empty() {
String::new()
} else {
let labels: Vec<String> = metric
.labels
.iter()
.map(|(k, v)| format!("{}=\"{}\"", k, v))
.collect();
format!("{{{}}}", labels.join(","))
};
match &metric.value {
MetricValue::Counter(value) => {
format!("{}{} {}\n", metric.name, labels_str, value)
}
MetricValue::Gauge(value) => {
format!("{}{} {}\n", metric.name, labels_str, value)
}
MetricValue::Histogram(_) => {
format!("{}{} 0\n", metric.name, labels_str)
}
MetricValue::Summary(summary) => {
format!(
"{}_sum{} {}\n{}_count{} {}\n",
metric.name, labels_str, summary.sum, metric.name, labels_str, summary.count
)
}
MetricValue::Distribution(dist) => {
format!("{}{} {}\n", metric.name, labels_str, dist.mean)
}
}
}
}
impl MetricExporter for PrometheusExporter {
fn export(&self, metrics: &[Metric]) -> Result<()> {
self.buffer.write().extend_from_slice(metrics);
Ok(())
}
fn flush(&self) -> Result<()> {
let metrics = {
let mut buffer = self.buffer.write();
std::mem::take(&mut *buffer)
};
if metrics.is_empty() {
return Ok(());
}
let body: String = metrics
.iter()
.map(|m| self.format_prometheus_metric(m))
.collect();
#[cfg(feature = "http-exporter")]
{
self.client
.post(&self.endpoint)
.header(reqwest::header::CONTENT_TYPE, "text/plain; version=0.0.4")
.body(body)
.send()?
.error_for_status()?;
Ok(())
}
#[cfg(not(feature = "http-exporter"))]
{
let _ = body;
Err(crate::error::ObservabilityError::ExporterFeatureDisabled(
format!(
"PrometheusExporter::flush dropped {} metric(s) destined for '{}': this build \
was compiled without the 'http-exporter' feature, so there is no transport to \
push metrics to a Prometheus push gateway. Rebuild with the 'http-exporter' \
feature enabled to actually deliver metrics.",
metrics.len(),
self.endpoint
),
))
}
}
fn name(&self) -> &str {
"prometheus"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_counter_no_labels() {
let exporter = PrometheusExporter::new("http://localhost:9091".to_string());
let metric = Metric::counter("test_counter".to_string(), 42);
assert_eq!(
exporter.format_prometheus_metric(&metric),
"test_counter 42\n"
);
}
#[test]
fn test_format_counter_with_labels() {
let exporter = PrometheusExporter::new("http://localhost:9091".to_string());
let metric = Metric::counter("foo".to_string(), 1).with_label("a", "b");
assert_eq!(
exporter.format_prometheus_metric(&metric),
"foo{a=\"b\"} 1\n"
);
}
#[test]
fn test_format_gauge_no_labels() {
let exporter = PrometheusExporter::new("http://localhost:9091".to_string());
let metric = Metric::gauge("temperature".to_string(), 23.5);
assert_eq!(
exporter.format_prometheus_metric(&metric),
"temperature 23.5\n"
);
}
#[test]
fn test_prometheus_exporter_flush_empty_buffer_is_ok() {
let exporter = PrometheusExporter::new("http://localhost:9091".to_string());
assert!(exporter.flush().is_ok());
}
#[test]
fn test_prometheus_exporter_export_buffers_metrics() {
let exporter = PrometheusExporter::new("http://localhost:9091".to_string());
let metrics = vec![Metric::counter("test_counter".to_string(), 42)];
assert!(exporter.export(&metrics).is_ok());
assert_eq!(exporter.buffer.read().len(), 1);
}
#[cfg(not(feature = "http-exporter"))]
#[test]
fn test_flush_without_http_exporter_feature_errors_instead_of_dropping_silently() {
let exporter = PrometheusExporter::new("http://localhost:9091/metrics".to_string());
let metrics = vec![Metric::counter("test_counter".to_string(), 42)];
exporter.export(&metrics).expect("export should succeed");
let result = exporter.flush();
assert!(
result.is_err(),
"flush() must not silently report success when there is no transport \
(http-exporter feature disabled) to deliver metrics"
);
assert!(exporter.flush().is_ok());
}
#[cfg(feature = "http-exporter")]
fn spawn_mock_push_gateway() -> (String, std::sync::mpsc::Receiver<String>) {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock push gateway");
let addr = listener.local_addr().expect("mock gateway local addr");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(500)));
let mut buf = [0u8; 8192];
let mut received = Vec::new();
loop {
match stream.read(&mut buf) {
Ok(0) => break,
Ok(n) => received.extend_from_slice(&buf[..n]),
Err(_) => break,
}
}
let text = String::from_utf8_lossy(&received).to_string();
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
let _ = tx.send(text);
}
});
(format!("http://{addr}/metrics"), rx)
}
#[cfg(feature = "http-exporter")]
#[test]
fn test_flush_posts_formatted_metrics_to_push_gateway() {
let (endpoint, rx) = spawn_mock_push_gateway();
let exporter = PrometheusExporter::new(endpoint);
let metrics = vec![Metric::counter("test_counter".to_string(), 42)];
exporter.export(&metrics).expect("export should succeed");
exporter
.flush()
.expect("flush should succeed and POST to the mock push gateway");
let received = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("mock push gateway should have received a request");
assert!(received.starts_with("POST"), "request was: {received}");
assert!(
received.contains("test_counter 42"),
"request body should contain the formatted metric, was: {received}"
);
}
#[cfg(feature = "http-exporter")]
#[test]
fn test_flush_errors_on_unreachable_endpoint() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
drop(listener);
let exporter = PrometheusExporter::new(format!("http://{addr}/metrics"));
let metrics = vec![Metric::counter("test_counter".to_string(), 42)];
exporter.export(&metrics).expect("export should succeed");
let result = exporter.flush();
assert!(
result.is_err(),
"flush() against an unreachable endpoint must return an error, not silently succeed"
);
}
}