use anyhow::Result;
use prometheus::{CounterVec, GaugeVec, HistogramVec, IntGauge, Opts, Registry};
use std::time::Instant;
#[derive(Clone)]
pub struct ScraperCollector {
scrape_duration_seconds: HistogramVec,
scrape_errors_total: CounterVec,
last_scrape_timestamp: GaugeVec,
last_scrape_success: GaugeVec,
metrics_total: IntGauge,
scrapes_total: IntGauge,
}
impl Default for ScraperCollector {
fn default() -> Self {
Self::new()
}
}
impl ScraperCollector {
#[must_use]
#[allow(clippy::expect_used)]
pub fn new() -> Self {
let scrape_duration_seconds = HistogramVec::new(
prometheus::HistogramOpts::new(
"pg_exporter_collector_scrape_duration_seconds",
"Time spent scraping each collector in seconds",
)
.buckets(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]),
&["collector"],
)
.expect("pg_exporter_collector_scrape_duration_seconds");
let scrape_errors_total = CounterVec::new(
Opts::new(
"pg_exporter_collector_scrape_errors_total",
"Total number of scrape errors per collector",
),
&["collector"],
)
.expect("pg_exporter_collector_scrape_errors_total");
let last_scrape_timestamp = GaugeVec::new(
Opts::new(
"pg_exporter_collector_last_scrape_timestamp_seconds",
"Unix timestamp of the last scrape attempt per collector",
),
&["collector"],
)
.expect("pg_exporter_collector_last_scrape_timestamp_seconds");
let last_scrape_success = GaugeVec::new(
Opts::new(
"pg_exporter_collector_last_scrape_success",
"Whether the last scrape was successful (1=success, 0=failure)",
),
&["collector"],
)
.expect("pg_exporter_collector_last_scrape_success");
let metrics_total = IntGauge::with_opts(Opts::new(
"pg_exporter_metrics_total",
"Total active time series / cardinality (non-comment, non-empty lines)",
))
.expect("pg_exporter_metrics_total");
let scrapes_total = IntGauge::with_opts(Opts::new(
"pg_exporter_scrapes_total",
"Total number of scrapes performed since start",
))
.expect("pg_exporter_scrapes_total");
Self {
scrape_duration_seconds,
scrape_errors_total,
last_scrape_timestamp,
last_scrape_success,
metrics_total,
scrapes_total,
}
}
#[must_use]
pub fn scrapes_total(&self) -> i64 {
self.scrapes_total.get()
}
#[must_use]
pub fn start_scrape(&self, collector_name: &'static str) -> ScrapeTimer {
ScrapeTimer {
collector_name,
start: Instant::now(),
scraper: self.clone(),
recorded: false,
}
}
pub fn update_metrics_count(&self, count: i64) {
self.metrics_total.set(count);
}
pub fn increment_scrapes(&self) {
self.scrapes_total.inc();
}
fn record_success(&self, collector_name: &'static str, duration: f64) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
self.scrape_duration_seconds
.with_label_values(&[collector_name])
.observe(duration);
self.last_scrape_timestamp
.with_label_values(&[collector_name])
.set(timestamp);
self.last_scrape_success
.with_label_values(&[collector_name])
.set(1.0);
}
fn record_error(&self, collector_name: &'static str) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
self.scrape_errors_total
.with_label_values(&[collector_name])
.inc();
self.last_scrape_timestamp
.with_label_values(&[collector_name])
.set(timestamp);
self.last_scrape_success
.with_label_values(&[collector_name])
.set(0.0);
}
pub fn register(&self, registry: &Registry) -> Result<()> {
registry.register(Box::new(self.scrape_duration_seconds.clone()))?;
registry.register(Box::new(self.scrape_errors_total.clone()))?;
registry.register(Box::new(self.last_scrape_timestamp.clone()))?;
registry.register(Box::new(self.last_scrape_success.clone()))?;
registry.register(Box::new(self.metrics_total.clone()))?;
registry.register(Box::new(self.scrapes_total.clone()))?;
Ok(())
}
}
impl crate::collectors::Collector for ScraperCollector {
fn name(&self) -> &'static str {
"scraper"
}
fn register_metrics(&self, registry: &Registry) -> Result<()> {
self.register(registry)
}
fn collect<'a>(&'a self, _pool: &'a sqlx::PgPool) -> futures::future::BoxFuture<'a, Result<()>> {
Box::pin(async move { Ok(()) })
}
fn enabled_by_default(&self) -> bool {
false
}
}
pub struct ScrapeTimer {
collector_name: &'static str,
start: Instant,
scraper: ScraperCollector,
recorded: bool,
}
impl ScrapeTimer {
pub fn success(mut self) {
self.recorded = true;
let duration = self.start.elapsed().as_secs_f64();
self.scraper.record_success(self.collector_name, duration);
}
pub fn error(mut self) {
self.recorded = true;
self.scraper.record_error(self.collector_name);
}
}
impl Drop for ScrapeTimer {
fn drop(&mut self) {
if self.recorded {
return;
}
let duration = self.start.elapsed().as_secs_f64();
self.scraper.record_success(self.collector_name, duration);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
#[allow(clippy::unwrap_used)]
fn test_scraper_collector_new() {
let scraper = ScraperCollector::new();
assert_eq!(scraper.metrics_total.get(), 0);
assert_eq!(scraper.scrapes_total.get(), 0);
}
#[test]
#[allow(clippy::unwrap_used)]
fn test_scraper_collector_registers_without_error() {
let scraper = ScraperCollector::new();
let registry = Registry::new();
assert!(scraper.register(®istry).is_ok());
}
#[test]
#[allow(clippy::unwrap_used)]
#[allow(clippy::expect_used)]
fn test_scrape_timer_records_duration() {
let scraper = ScraperCollector::new();
let registry = Registry::new();
scraper.register(®istry).unwrap();
{
let timer = scraper.start_scrape("test_collector");
thread::sleep(Duration::from_millis(10));
timer.success();
}
let metrics = registry.gather();
let duration_metric = metrics
.iter()
.find(|m| m.name() == "pg_exporter_collector_scrape_duration_seconds")
.expect("duration metric should exist");
let metric = duration_metric.get_metric().first().expect("metric should have at least one sample");
assert_eq!(
metric.get_histogram().get_sample_count(),
1,
"Should record exactly one sample"
);
}
#[test]
#[allow(clippy::unwrap_used)]
#[allow(clippy::expect_used)]
fn test_scrape_timer_records_error() {
let scraper = ScraperCollector::new();
let registry = Registry::new();
scraper.register(®istry).unwrap();
{
let timer = scraper.start_scrape("test_collector");
timer.error();
}
let metrics = registry.gather();
let error_metric = metrics
.iter()
.find(|m| m.name() == "pg_exporter_collector_scrape_errors_total")
.expect("error metric should exist");
let metric = error_metric.get_metric().first().expect("metric should have at least one sample");
assert!((metric.get_counter().value() - 1.0).abs() < f64::EPSILON, "Should record exactly one error");
let duration_metric = metrics
.iter()
.find(|m| m.name() == "pg_exporter_collector_scrape_duration_seconds");
if let Some(m) = duration_metric {
let metrics = m.get_metric();
if !metrics.is_empty() {
assert_eq!(
metrics.first().expect("metric should have at least one sample").get_histogram().get_sample_count(),
0,
"Should not record duration on error"
);
}
}
}
#[test]
fn test_update_metrics_count() {
let scraper = ScraperCollector::new();
scraper.update_metrics_count(42);
assert_eq!(scraper.metrics_total.get(), 42);
}
#[test]
fn test_increment_scrapes() {
let scraper = ScraperCollector::new();
scraper.increment_scrapes();
assert_eq!(scraper.scrapes_total.get(), 1);
scraper.increment_scrapes();
assert_eq!(scraper.scrapes_total.get(), 2);
}
}