rename "plotter" as "prometheus", make plot feature optional in signal-gateway

This commit is contained in:
Chris Beck
2025-12-04 23:35:04 -07:00
parent 7b7b6bcac7
commit 5c6bbd6f96
7 changed files with 311 additions and 238 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ assigning_clones = "allow"
result_large_err = "allow" result_large_err = "allow"
[workspace.dependencies] [workspace.dependencies]
prometheus-http-client = { path = "prometheus-http-client" } prometheus-http-client = { path = "prometheus-http-client", default-features = false }
async-trait = "0.1" async-trait = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
+9 -4
View File
@@ -6,11 +6,14 @@ edition.workspace = true
[lints] [lints]
workspace = true workspace = true
[features]
default = ["plot"]
plot = ["prometheus-http-client/plot", "dep:chrono-tz", "dep:rand", "dep:walkdir"]
[dependencies] [dependencies]
prometheus-http-client = { workspace = true } prometheus-http-client = { workspace = true, default-features = false }
chrono = { workspace = true } chrono = { workspace = true }
chrono-tz = { workspace = true }
conf = { workspace = true } conf = { workspace = true }
conf-extra = { workspace = true } conf-extra = { workspace = true }
displaydoc = { workspace = true } displaydoc = { workspace = true }
@@ -21,7 +24,6 @@ humantime = { workspace = true }
hyper = { workspace = true } hyper = { workspace = true }
hyper-util = { workspace = true } hyper-util = { workspace = true }
jsonrpsee = { workspace = true } jsonrpsee = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
rustls = { workspace = true } rustls = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@@ -33,4 +35,7 @@ tokio-util = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
url = { workspace = true } url = { workspace = true }
walkdir = { workspace = true }
chrono-tz = { workspace = true, optional = true }
rand = { workspace = true, optional = true }
walkdir = { workspace = true, optional = true }
@@ -44,11 +44,13 @@ impl<T> CircularBuffer<T> {
} }
/// Returns true if the buffer is empty. /// Returns true if the buffer is empty.
#[allow(unused)]
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.buf.is_empty() self.buf.is_empty()
} }
/// Returns the capacity of the buffer. /// Returns the capacity of the buffer.
#[allow(unused)]
pub fn capacity(&self) -> usize { pub fn capacity(&self) -> usize {
self.capacity self.capacity
} }
+55 -38
View File
@@ -1,7 +1,7 @@
use crate::{ use crate::{
http::{AlertMessage, Status}, http::{AlertMessage, Status},
jsonrpc::{Envelope, RpcClient, RpcClientError, SignalMessage, connect_tcp}, jsonrpc::{Envelope, RpcClient, RpcClientError, SignalMessage, connect_tcp},
plotter::{Plotter, PlotterConfig}, prometheus::{Prometheus, PrometheusConfig},
}; };
use conf::{Conf, Subcommands}; use conf::{Conf, Subcommands};
use futures_util::FutureExt; use futures_util::FutureExt;
@@ -10,7 +10,9 @@ use hyper::{Method, Request, Response, StatusCode, body::Incoming};
use prometheus_http_client::{AlertStatus, ExtractLabels}; use prometheus_http_client::{AlertStatus, ExtractLabels};
//use jsonrpsee::async_client::{Client as JsonRpcClient, Error as JsonRpcError}; //use jsonrpsee::async_client::{Client as JsonRpcClient, Error as JsonRpcError};
use chrono::Utc; use chrono::Utc;
use std::{collections::HashMap, error::Error, fmt::Write, net::SocketAddr, time::Duration}; use std::{
collections::HashMap, error::Error, fmt::Write, net::SocketAddr, path::PathBuf, time::Duration,
};
use syslog_rfc5424::SyslogMessage; use syslog_rfc5424::SyslogMessage;
use tokio::{ use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
@@ -45,7 +47,7 @@ pub struct GatewayConfig {
#[conf(repeat, long, env)] #[conf(repeat, long, env)]
pub admin_uuid: Vec<String>, pub admin_uuid: Vec<String>,
#[conf(flatten)] #[conf(flatten)]
pub plotter: Option<PlotterConfig>, pub prometheus: Option<PrometheusConfig>,
#[conf(flatten)] #[conf(flatten)]
pub log_handler: LogHandlerConfig, pub log_handler: LogHandlerConfig,
} }
@@ -58,6 +60,7 @@ struct GatewayCommandWrapper {
} }
/// Commands that can be sent to the gateway (prefixed with /) /// Commands that can be sent to the gateway (prefixed with /)
#[allow(unused)]
#[derive(Clone, Debug, Subcommands)] #[derive(Clone, Debug, Subcommands)]
enum GatewayCommand { enum GatewayCommand {
/// Show recent log messages /// Show recent log messages
@@ -171,7 +174,7 @@ struct AdminMessage {
/// The origin of the message (app + host), if from syslog /// The origin of the message (app + host), if from syslog
origin: Option<Origin>, origin: Option<Origin>,
text: String, text: String,
attachment_paths: Vec<String>, attachment_paths: Vec<PathBuf>,
/// Short summary for logging (e.g., alert names for prometheus). /// Short summary for logging (e.g., alert names for prometheus).
/// If None, the consumer will use a truncated slice of `text` for logging. /// If None, the consumer will use a truncated slice of `text` for logging.
summary: Option<String>, summary: Option<String>,
@@ -193,7 +196,7 @@ pub struct Gateway {
admin_mq_tx: UnboundedSender<AdminMessage>, admin_mq_tx: UnboundedSender<AdminMessage>,
admin_mq_rx: Mutex<UnboundedReceiver<AdminMessage>>, admin_mq_rx: Mutex<UnboundedReceiver<AdminMessage>>,
token: CancellationToken, token: CancellationToken,
plotter: Option<Plotter>, prometheus: Option<Prometheus>,
/// Log handlers keyed by origin (app + host). Lazily created when first message from an origin arrives. /// Log handlers keyed by origin (app + host). Lazily created when first message from an origin arrives.
log_handlers: RwLock<HashMap<Origin, LogHandler>>, log_handlers: RwLock<HashMap<Origin, LogHandler>>,
} }
@@ -202,19 +205,19 @@ impl Gateway {
pub async fn new(config: GatewayConfig, token: CancellationToken) -> Self { pub async fn new(config: GatewayConfig, token: CancellationToken) -> Self {
let (admin_mq_tx, admin_mq_rx) = unbounded_channel(); let (admin_mq_tx, admin_mq_rx) = unbounded_channel();
let plotter = config let prometheus = config
.plotter .prometheus
.as_ref() .as_ref()
.map(|plotter_config| Plotter::new(plotter_config.clone())) .map(|pc| Prometheus::new(pc.clone()))
.transpose() .transpose()
.expect("Invalid plotter config"); .expect("Invalid prometheus config");
Self { Self {
config, config,
admin_mq_tx, admin_mq_tx,
admin_mq_rx: Mutex::new(admin_mq_rx), admin_mq_rx: Mutex::new(admin_mq_rx),
token, token,
plotter, prometheus,
log_handlers: RwLock::new(HashMap::new()), log_handlers: RwLock::new(HashMap::new()),
} }
} }
@@ -273,11 +276,12 @@ impl Gateway {
} else { } else {
msg.text msg.text
}; };
let attachments = msg.attachment_paths.into_iter().map(|p| p.to_str().unwrap().to_owned()).collect();
SignalMessage { SignalMessage {
sender: self.config.signal_account.clone(), sender: self.config.signal_account.clone(),
recipient: self.config.admin_uuid.clone(), recipient: self.config.admin_uuid.clone(),
message, message,
attachments: msg.attachment_paths, attachments,
}.send(signal_cli).await?; }.send(signal_cli).await?;
} else { } else {
warn!("admin_mq_rx is closed, halting service"); warn!("admin_mq_rx is closed, halting service");
@@ -307,12 +311,15 @@ impl Gateway {
continue; continue;
} }
let (resp, _) = join!(self.handle_signal_admin_message(&msg.envelope), let (resp, _) = join!(
msg.envelope.send_read_receipt(signal_cli, &self.config.signal_account).map(|result| { self.handle_signal_admin_message(&msg.envelope),
if let Err(err) = result { msg.envelope.send_read_receipt(signal_cli, &self.config.signal_account).map(|result| {
warn!("Couldn't send read receipt: {err}"); if let Err(err) = result {
} warn!("Couldn't send read receipt: {err}");
})); }
})
);
let (message, attachments) = resp.unwrap_or_else( let (message, attachments) = resp.unwrap_or_else(
|(code, msg)| { |(code, msg)| {
let text = format!("{code}: {msg}"); let text = format!("{code}: {msg}");
@@ -321,6 +328,8 @@ impl Gateway {
} }
); );
let attachments = attachments.into_iter().map(|p| p.to_str().expect("attachments must have utf8 paths").to_owned()).collect();
SignalMessage { SignalMessage {
sender: self.config.signal_account.clone(), sender: self.config.signal_account.clone(),
recipient: vec![msg.envelope.source_uuid.clone()], recipient: vec![msg.envelope.source_uuid.clone()],
@@ -339,7 +348,7 @@ impl Gateway {
async fn handle_signal_admin_message( async fn handle_signal_admin_message(
&self, &self,
msg: &Envelope, msg: &Envelope,
) -> Result<(String, Vec<String>), (u16, Box<dyn Error>)> { ) -> Result<(String, Vec<PathBuf>), (u16, Box<dyn Error>)> {
let data = msg.data_message.as_ref().unwrap(); let data = msg.data_message.as_ref().unwrap();
// Admin messages starting with / are handled by gateway // Admin messages starting with / are handled by gateway
@@ -432,7 +441,7 @@ impl Gateway {
async fn handle_gateway_command( async fn handle_gateway_command(
&self, &self,
cmd: GatewayCommand, cmd: GatewayCommand,
) -> Result<(String, Vec<String>), (u16, Box<dyn Error>)> { ) -> Result<(String, Vec<PathBuf>), (u16, Box<dyn Error>)> {
match cmd { match cmd {
GatewayCommand::Log { filter } => { GatewayCommand::Log { filter } => {
let handlers = self.log_handlers.read().await; let handlers = self.log_handlers.read().await;
@@ -457,12 +466,12 @@ impl Gateway {
Ok((text, vec![])) Ok((text, vec![]))
} }
GatewayCommand::Query { query } => { GatewayCommand::Query { query } => {
let plotter = self let prometheus = self
.plotter .prometheus
.as_ref() .as_ref()
.ok_or_else(|| (500, "prometheus was not configured".into()))?; .ok_or_else(|| (500, "prometheus was not configured".into()))?;
match plotter.oneoff_query(query).await { match prometheus.oneoff_query(query).await {
Ok(( Ok((
ExtractLabels { ExtractLabels {
name, name,
@@ -491,26 +500,31 @@ impl Gateway {
Err(err) => Err((500, err)), Err(err) => Err((500, err)),
} }
} }
#[cfg(feature = "plot")]
GatewayCommand::Plot { query, duration } => { GatewayCommand::Plot { query, duration } => {
let plotter = self let prometheus = self
.plotter .prometheus
.as_ref() .as_ref()
.ok_or_else(|| (500, "prometheus was not configured".into()))?; .ok_or_else(|| (500, "prometheus was not configured".into()))?;
plotter.purge_old_plots(); prometheus.purge_old_plots();
match plotter.create_oneoff_plot(query.clone(), duration).await { match prometheus.create_oneoff_plot(query.clone(), duration).await {
Ok(filename) => Ok((query, vec![filename])), Ok(filename) => Ok((query, vec![filename])),
Err(err) => Err((500, err)), Err(err) => Err((500, err)),
} }
} }
#[cfg(not(feature = "plot"))]
GatewayCommand::Plot { .. } => {
Err((500, "the plot feature was not enabled at build time".into()))
}
GatewayCommand::Series { matchers } => { GatewayCommand::Series { matchers } => {
let plotter = self let prometheus = self
.plotter .prometheus
.as_ref() .as_ref()
.ok_or_else(|| (500, "prometheus was not configured".into()))?; .ok_or_else(|| (500, "prometheus was not configured".into()))?;
let matcher_refs: Vec<&str> = matchers.iter().map(|s| s.as_str()).collect(); let matcher_refs: Vec<&str> = matchers.iter().map(|s| s.as_str()).collect();
match plotter.series(&matcher_refs).await { match prometheus.series(&matcher_refs).await {
Ok(data) => { Ok(data) => {
let mut text = data.iter().fold(String::default(), |mut buf, kv| { let mut text = data.iter().fold(String::default(), |mut buf, kv| {
writeln!(&mut buf, "{kv:?}").unwrap(); writeln!(&mut buf, "{kv:?}").unwrap();
@@ -525,13 +539,13 @@ impl Gateway {
} }
} }
GatewayCommand::Labels { matchers } => { GatewayCommand::Labels { matchers } => {
let plotter = self let prometheus = self
.plotter .prometheus
.as_ref() .as_ref()
.ok_or_else(|| (500, "prometheus was not configured".into()))?; .ok_or_else(|| (500, "prometheus was not configured".into()))?;
let matcher_refs: Vec<&str> = matchers.iter().map(|s| s.as_str()).collect(); let matcher_refs: Vec<&str> = matchers.iter().map(|s| s.as_str()).collect();
match plotter.labels(&matcher_refs).await { match prometheus.labels(&matcher_refs).await {
Ok(data) => { Ok(data) => {
let mut text = data.iter().fold(String::default(), |mut buf, l| { let mut text = data.iter().fold(String::default(), |mut buf, l| {
writeln!(&mut buf, "{l}").unwrap(); writeln!(&mut buf, "{l}").unwrap();
@@ -546,12 +560,12 @@ impl Gateway {
} }
} }
GatewayCommand::Alerts => { GatewayCommand::Alerts => {
let plotter = self let prometheus = self
.plotter .prometheus
.as_ref() .as_ref()
.ok_or_else(|| (500, "prometheus was not configured".into()))?; .ok_or_else(|| (500, "prometheus was not configured".into()))?;
match plotter.alerts().await { match prometheus.alerts().await {
Ok(data) => { Ok(data) => {
let now = Utc::now(); let now = Utc::now();
let mut text = data.iter().fold(String::default(), |mut buf, alert| { let mut text = data.iter().fold(String::default(), |mut buf, alert| {
@@ -603,11 +617,14 @@ impl Gateway {
.format_alert_text(&alert_msg) .format_alert_text(&alert_msg)
.unwrap_or_else(|err| format!("error formatting alert text: {err}:\n{alert_msg:#?}")); .unwrap_or_else(|err| format!("error formatting alert text: {err}:\n{alert_msg:#?}"));
#[allow(unused_mut)]
let mut attachment_paths = vec![]; let mut attachment_paths = vec![];
if let Some(plotter) = self.plotter.as_ref() {
plotter.purge_old_plots(); #[cfg(feature = "plot")]
if let Some(prometheus) = self.prometheus.as_ref() {
prometheus.purge_old_plots();
for alert in alert_msg.alerts.iter() { for alert in alert_msg.alerts.iter() {
match plotter.create_alert_plot(alert).await { match prometheus.create_alert_plot(alert).await {
Ok(path) => { Ok(path) => {
attachment_paths.push(path); attachment_paths.push(path);
} }
+1 -1
View File
@@ -14,7 +14,7 @@ pub mod http;
mod human_duration; mod human_duration;
mod init_logging; mod init_logging;
pub mod jsonrpc; pub mod jsonrpc;
pub mod plotter; pub mod prometheus;
pub mod transports; pub mod transports;
use config::Config; use config::Config;
@@ -1,217 +1,54 @@
use crate::http::Alert;
use chrono::{FixedOffset, Local, Offset, Utc};
use chrono_tz::Tz;
use conf::Conf; use conf::Conf;
use prometheus_http_client::{ use prometheus_http_client::{
AlertInfo, AlertsRequest, ExtractLabels, Labels, LabelsRequest, MetricTimeseries, MetricVal, AlertInfo, AlertsRequest, ExtractLabels, Labels, LabelsRequest, MetricVal, MetricValue,
MetricValue, PromRequest, QueryRangeRequest, QueryRequest, SeriesRequest, PromRequest, QueryRequest, SeriesRequest,
plot::{PlotStyle, PlotThreshold},
}; };
use rand::RngCore;
use reqwest::Client as ReqwestClient; use reqwest::Client as ReqwestClient;
use std::{error::Error, str::FromStr, time::Duration}; use std::error::Error;
use tracing::{info, warn}; use tracing::info;
use walkdir::WalkDir;
#[cfg(feature = "plot")]
mod plot;
#[cfg(feature = "plot")]
use plot::{PlotConfig, PlotThreshold};
#[cfg(feature = "plot")]
use crate::http::Alert;
#[cfg(feature = "plot")]
use prometheus_http_client::QueryRangeRequest;
#[cfg(feature = "plot")]
use std::{path::PathBuf, str::FromStr, time::Duration};
/// Configures our prometheus API client
#[derive(Clone, Conf, Debug)] #[derive(Clone, Conf, Debug)]
#[conf(at_most_one_of_fields(utc_offset, timezone))] pub struct PrometheusConfig {
pub struct PlotterConfig { /// Address of prometheus host. Should start with http and usually indicate port 9090
#[conf(long, env)] #[conf(long, env)]
pub prometheus_host: String, pub prometheus_host: String,
#[conf(long, env, default_value = "30m", value_parser = conf_extra::parse_duration)] #[cfg(feature = "plot")]
pub plot_age_limit: Duration, /// Configuration options for generated plots
/// Fixed timezone offset for plot timestamps, e.g. "+05:30" or "-08:00". #[conf(flatten, prefix)]
/// Mutually exclusive with --timezone. pub plot: PlotConfig,
#[conf(long, env)]
pub utc_offset: Option<FixedOffset>,
/// Timezone name for plot timestamps, e.g. "US/Mountain" or "Europe/Paris".
/// Mutually exclusive with --utc-offset.
#[conf(long, env)]
pub timezone: Option<Tz>,
/// Stroke width for plot lines
#[conf(long, env, default_value = "2")]
pub line_width: u32,
/// Plot dimensions in pixels (width, height), e.g. "1920,1200" or "(1920, 1200)"
#[conf(long, env, default_value = "1920,1200", value_parser = parse_dimensions)]
pub plot_dimensions: (u32, u32),
} }
fn parse_dimensions(s: &str) -> Result<(u32, u32), String> { /// Prometheus HTTP API + plotting if configured
let s = s.trim(); pub struct Prometheus {
// Strip optional parentheses config: PrometheusConfig,
let s = s
.strip_prefix('(')
.and_then(|s| s.strip_suffix(')'))
.unwrap_or(s);
let (left, right) = s
.split_once(',')
.ok_or_else(|| format!("expected 'width,height', got '{s}'"))?;
let width = left
.trim()
.parse::<u32>()
.map_err(|e| format!("invalid width: {e}"))?;
let height = right
.trim()
.parse::<u32>()
.map_err(|e| format!("invalid height: {e}"))?;
Ok((width, height))
}
pub struct Plotter {
config: PlotterConfig,
plot_dir: String,
reqwest_client: ReqwestClient, reqwest_client: ReqwestClient,
skip_labels: Vec<String>,
} }
impl Plotter { impl Prometheus {
pub fn new(config: PlotterConfig) -> Result<Self, &'static str> { /// Create a new Prometheus client object
if config.utc_offset.is_some() && config.timezone.is_some() { pub fn new(config: PrometheusConfig) -> Result<Self, &'static str> {
return Err("Cannot specify both --utc-offset and --timezone");
}
let plot_dir = "/tmp".into();
let reqwest_client = ReqwestClient::new(); let reqwest_client = ReqwestClient::new();
let skip_labels = vec!["job".into(), "instance".into()];
Ok(Self { Ok(Self {
config, config,
plot_dir,
reqwest_client, reqwest_client,
skip_labels,
}) })
} }
fn create_plot( /// Evaluate a PromQL query, returning a list of matching current timeseries values and their labels.
&self,
matrix: &[MetricTimeseries],
threshold: Option<PlotThreshold>,
title: Option<&str>,
) -> Result<String, Box<dyn Error>> {
let filename = format!(
"{dir}/plot-{num}.png",
dir = self.plot_dir,
num = rand::rng().next_u64()
);
let utc_offset = if let Some(offset) = self.config.utc_offset {
offset
} else if let Some(tz) = self.config.timezone {
Utc::now().with_timezone(&tz).offset().fix()
} else {
*Local::now().offset()
};
let mut plot_style = PlotStyle::default()
.dark_mode()
.with_line_width(self.config.line_width)
.with_drawing_area(self.config.plot_dimensions)
.with_utc_offset(utc_offset)
.with_skip_labels(self.skip_labels.clone());
if let Some(title) = title {
plot_style = plot_style.with_title(title);
}
plot_style.plot_timeseries(&filename, matrix, threshold)?;
Ok(filename)
}
pub fn purge_old_plots(&self) {
for entry in WalkDir::new(&self.plot_dir)
.min_depth(1)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
if !entry.file_type().is_file() {
continue;
}
let Some(ext) = entry.path().extension() else {
continue;
};
if ext != "png" && ext != ".png" {
continue;
}
let path = entry.path().display();
let Ok(metadata) = entry
.metadata()
.inspect_err(|err| warn!("Couldn't get metadata for {path}: {err}"))
else {
continue;
};
let Ok(time) = metadata
.created()
.inspect_err(|err| warn!("Couldn't get creation time for {path}: {err}"))
else {
continue;
};
let Ok(elapsed) = time
.elapsed()
.inspect_err(|err| warn!("Elapsed time calculation failed for {path}: {err}"))
else {
continue;
};
if elapsed > self.config.plot_age_limit
&& let Err(err) = std::fs::remove_file(entry.path())
{
warn!("Couldn't remove old png file {path}: {err}");
}
}
}
pub async fn create_alert_plot(&self, alert: &Alert) -> Result<String, Box<dyn Error>> {
let expr = alert.parse_expr_from_generator_url()?;
// Parse expressions like "query < 0.09" or "query < 0.09 and on (instance) up{...}"
// We look for comparison operators and extract the threshold
let (base_query, threshold) = parse_alert_expr(&expr)?;
// Build label selector from alert labels (excluding job/instance)
let label_selector = build_label_selector(&alert.labels, &self.skip_labels);
let query = if label_selector.is_empty() {
base_query.to_owned()
} else {
format!("{base_query}{{{label_selector}}}")
};
let now = Utc::now();
let elapsed = now - alert.starts_at;
// Extend elapsed by 210%, but use at least plot_age_limit (default 30m)
let lengthen = elapsed
.checked_mul(31)
.and_then(|e| e.checked_div(10))
.ok_or("timedelta overflow")?;
let min_range = chrono::TimeDelta::from_std(self.config.plot_age_limit)?;
let range = lengthen.max(min_range);
info!("Prom range query: {query}");
let matrix = QueryRangeRequest::builder(query.clone())
.range(now - range..now)
.build()
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
.await?
.into_matrix()?;
self.create_plot(&matrix, Some(threshold), Some(&query))
}
pub async fn create_oneoff_plot(
&self,
query: String,
since: Duration,
) -> Result<String, Box<dyn Error>> {
info!("Prom range query: {query}");
let matrix = QueryRangeRequest::builder(query.to_owned())
.since(since)
.build()
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
.await?
.into_matrix()?;
self.create_plot(&matrix, None, Some(&query))
}
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
pub async fn oneoff_query( pub async fn oneoff_query(
&self, &self,
@@ -223,12 +60,13 @@ impl Plotter {
.await? .await?
.into_vector()?; .into_vector()?;
let labels = ExtractLabels::new(vector.iter().map(|mv| &mv.metric), &self.skip_labels); let labels = ExtractLabels::new(vector.iter().map(|mv| &mv.metric), &[]);
let values = vector.into_iter().map(|mv| mv.value).collect(); let values = vector.into_iter().map(|mv| mv.value).collect();
Ok((labels, values)) Ok((labels, values))
} }
/// Get the list of all timeseries, and all of their labels, matching given filters.
pub async fn series( pub async fn series(
&self, &self,
matches: impl IntoIterator<Item: AsRef<str>>, matches: impl IntoIterator<Item: AsRef<str>>,
@@ -240,6 +78,7 @@ impl Plotter {
.await?) .await?)
} }
/// Get the list of all existing labels, matching given filters
pub async fn labels( pub async fn labels(
&self, &self,
matches: impl IntoIterator<Item: AsRef<str>>, matches: impl IntoIterator<Item: AsRef<str>>,
@@ -251,6 +90,7 @@ impl Plotter {
.await?) .await?)
} }
/// Get the list of current alerts
pub async fn alerts(&self) -> Result<Vec<AlertInfo>, Box<dyn Error>> { pub async fn alerts(&self) -> Result<Vec<AlertInfo>, Box<dyn Error>> {
Ok(AlertsRequest {} Ok(AlertsRequest {}
.send_with_client(&self.reqwest_client, &self.config.prometheus_host) .send_with_client(&self.reqwest_client, &self.config.prometheus_host)
@@ -259,8 +99,75 @@ impl Plotter {
} }
} }
#[cfg(feature = "plot")]
impl Prometheus {
/// Purge old plots from the plot directory
pub fn purge_old_plots(&self) {
self.config.plot.purge_old_plots();
}
/// Create a new plot corresponding to a given alert. Returns a pathbuf if it is present
pub async fn create_alert_plot(&self, alert: &Alert) -> Result<PathBuf, Box<dyn Error>> {
use chrono::{TimeDelta, Utc};
let expr = alert.parse_expr_from_generator_url()?;
// Parse expressions like "query < 0.09" or "query < 0.09 and on (instance) up{...}"
// We look for comparison operators and extract the threshold
let (base_query, threshold) = parse_alert_expr(&expr)?;
// Build label selector from alert labels (excluding job/instance)
let label_selector = build_label_selector(&alert.labels, &[]);
let query = if label_selector.is_empty() {
base_query.to_owned()
} else {
format!("{base_query}{{{label_selector}}}")
};
let now = Utc::now();
let elapsed = now - alert.starts_at;
// Extend elapsed by 110%, but use at least 30m
let lengthen = elapsed
.checked_mul(21)
.and_then(|e| e.checked_div(10))
.ok_or("timedelta overflow")?;
let min_range = TimeDelta::minutes(30);
let range = lengthen.max(min_range);
info!("Prom range query: {query}");
let matrix = QueryRangeRequest::builder(query.clone())
.range(now - range..now)
.build()
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
.await?
.into_matrix()?;
self.config
.plot
.create_plot(&matrix, Some(threshold), Some(&query))
}
/// Create a plot for a given query and timeframe
pub async fn create_oneoff_plot(
&self,
query: String,
since: Duration,
) -> Result<PathBuf, Box<dyn Error>> {
info!("Prom range query: {query}");
let matrix = QueryRangeRequest::builder(query.to_owned())
.since(since)
.build()
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
.await?
.into_matrix()?;
self.config.plot.create_plot(&matrix, None, Some(&query))
}
}
/// Build a prometheus label selector string from alert labels, excluding specified labels. /// Build a prometheus label selector string from alert labels, excluding specified labels.
/// E.g., {"asset": "ETH", "job": "ec2"} with skip=["job"] -> `asset="ETH"` /// E.g., {"asset": "ETH", "job": "ec2"} with skip=["job"] -> `asset="ETH"`
#[cfg(feature = "plot")]
fn build_label_selector( fn build_label_selector(
labels: &std::collections::BTreeMap<String, String>, labels: &std::collections::BTreeMap<String, String>,
skip: &[String], skip: &[String],
@@ -281,6 +188,7 @@ fn build_label_selector(
/// - "rate(tick_successes[5m]) < 0.09 and on (instance) up{job=\"ec2\"}" /// - "rate(tick_successes[5m]) < 0.09 and on (instance) up{job=\"ec2\"}"
/// ///
/// Returns (base_query, threshold) where base_query is the part before the comparator. /// Returns (base_query, threshold) where base_query is the part before the comparator.
#[cfg(feature = "plot")]
fn parse_alert_expr(expr: &str) -> Result<(String, PlotThreshold), Box<dyn std::error::Error>> { fn parse_alert_expr(expr: &str) -> Result<(String, PlotThreshold), Box<dyn std::error::Error>> {
// Look for comparison operators with surrounding spaces // Look for comparison operators with surrounding spaces
for comparator in [" < ", " > "] { for comparator in [" < ", " > "] {
+141
View File
@@ -0,0 +1,141 @@
use chrono::{FixedOffset, Local, Offset, Utc};
use chrono_tz::Tz;
use conf::Conf;
use rand::RngCore;
use std::{error::Error, path::PathBuf, time::Duration};
use tracing::{error, warn};
use walkdir::WalkDir;
pub use prometheus_http_client::{
MetricTimeseries,
plot::{PlotStyle, PlotThreshold},
};
/// Configuration for plots generated from prometheus data
#[derive(Clone, Conf, Debug)]
#[conf(at_most_one_of_fields(utc_offset, timezone))]
pub struct PlotConfig {
/// Working directory for temporary plot files
#[conf(long, env, default_value = "/tmp")]
dir: PathBuf,
/// How long before temporary plot files are removed from the working directory
#[conf(long, env, default_value = "30m", value_parser = conf_extra::parse_duration)]
age_limit: Duration,
/// Fixed timezone offset for plot timestamps, e.g. "+05:30" or "-08:00".
/// Mutually exclusive with --timezone.
#[conf(long, env)]
utc_offset: Option<FixedOffset>,
/// Timezone name for plot timestamps, e.g. "US/Mountain" or "Europe/Paris".
/// Mutually exclusive with --utc-offset.
#[conf(long, env)]
timezone: Option<Tz>,
/// Stroke width for plot lines
#[conf(long, env, default_value = "2")]
line_width: u32,
/// Plot dimensions in pixels (width, height), e.g. "1920,1200" or "(1920, 1200)"
#[conf(long, env, default_value = "1920,1200", value_parser = parse_dimensions)]
dimensions: (u32, u32),
/// Don't show these labels in the plot title or legend
#[conf(long, env, default_value = "[\"job\", \"instance\"]", value_parser = serde_json::from_str)]
skip_labels: Vec<String>,
}
impl PlotConfig {
pub fn create_plot(
&self,
matrix: &[MetricTimeseries],
threshold: Option<PlotThreshold>,
title: Option<&str>,
) -> Result<PathBuf, Box<dyn Error>> {
let mut filename = self.dir.clone();
filename.push(format!("plot-{}", rand::rng().next_u64()));
filename.set_extension("png");
let utc_offset = if let Some(offset) = self.utc_offset {
offset
} else if let Some(tz) = self.timezone {
Utc::now().with_timezone(&tz).offset().fix()
} else {
*Local::now().offset()
};
let mut plot_style = PlotStyle::default()
.dark_mode()
.with_line_width(self.line_width)
.with_drawing_area(self.dimensions)
.with_utc_offset(utc_offset)
.with_skip_labels(self.skip_labels.clone());
if let Some(title) = title {
plot_style = plot_style.with_title(title);
}
plot_style.plot_timeseries(&filename, matrix, threshold)?;
Ok(filename)
}
pub fn purge_old_plots(&self) {
for entry in WalkDir::new(&self.dir)
.min_depth(1)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
if !entry.file_type().is_file() {
continue;
}
let Some(ext) = entry.path().extension() else {
continue;
};
if ext != "png" && ext != ".png" {
continue;
}
let path = entry.path().display();
let Ok(metadata) = entry
.metadata()
.inspect_err(|err| warn!("Couldn't get metadata for {path}: {err}"))
else {
continue;
};
let Ok(time) = metadata
.created()
.inspect_err(|err| warn!("Couldn't get creation time for {path}: {err}"))
else {
continue;
};
let Ok(elapsed) = time
.elapsed()
.inspect_err(|err| warn!("Elapsed time calculation failed for {path}: {err}"))
else {
continue;
};
if elapsed > self.age_limit
&& let Err(err) = std::fs::remove_file(entry.path())
{
error!("Couldn't remove old png file {path}: {err}");
}
}
}
}
fn parse_dimensions(s: &str) -> Result<(u32, u32), String> {
let s = s.trim();
// Strip optional parentheses
let s = s
.strip_prefix('(')
.and_then(|s| s.strip_suffix(')'))
.unwrap_or(s);
let (left, right) = s
.split_once(',')
.ok_or_else(|| format!("expected 'width,height', got '{s}'"))?;
let width = left
.trim()
.parse::<u32>()
.map_err(|e| format!("invalid width: {e}"))?;
let height = right
.trim()
.parse::<u32>()
.map_err(|e| format!("invalid height: {e}"))?;
Ok((width, height))
}