prometheus_http_client/
plot.rs

1//! Make a simple plot of time-series data from prometheus
2
3use crate::{ExtractLabels, MetricTimeseries};
4use chrono::{DateTime, FixedOffset, TimeZone, Utc};
5use plotters::prelude::*;
6use std::{
7    borrow::Borrow,
8    error::Error,
9    fmt::{Debug, Display, Write},
10    ops::Range,
11    path::Path,
12};
13
14/// Styling options for the plot
15#[non_exhaustive]
16pub struct PlotStyle {
17    // The pixel size of the plot
18    drawing_area: (u32, u32),
19    // The background color
20    background: RGBAColor,
21    // The grid color
22    grid: RGBAColor,
23    // The axis color
24    axis: RGBAColor,
25    // The text color
26    text_color: RGBAColor,
27    // The text font
28    text_font: String,
29    // The text size
30    text_size: u32,
31    // The caption size
32    caption_size: u32,
33    // The colors to use for lines. If there are more lines than this, then colors will be repeated.
34    data_colors: Vec<RGBAColor>,
35    // The colors to use for a "threshold" such as used in a PromQL alerting rule
36    threshold_color: RGBAColor,
37    // Labels to skip rendering of
38    skip_labels: Vec<String>,
39    // Timezone offset to use when labelling the timestamps being plotted
40    utc_offset: FixedOffset,
41    // Optional title override - used when prometheus aggregations remove __name__
42    title: Option<String>,
43    // The stroke width for data lines (default: 1)
44    line_width: u32,
45}
46
47impl Default for PlotStyle {
48    fn default() -> Self {
49        Self {
50            drawing_area: (1920, 1200),
51            background: WHITE.into(),
52            grid: RGBAColor(100, 100, 100, 0.5),
53            axis: BLACK.into(),
54            text_color: BLACK.into(),
55            text_font: "sans-serif".into(),
56            text_size: 18,
57            caption_size: 36,
58            data_colors: [
59                GREEN,
60                BLUE,
61                full_palette::ORANGE,
62                YELLOW,
63                MAGENTA,
64                full_palette::TEAL,
65                full_palette::PURPLE,
66            ]
67            .iter()
68            .cloned()
69            .map(Into::into)
70            .collect(),
71            threshold_color: RED.mix(0.2),
72            skip_labels: vec!["job".into(), "instance".into()],
73            utc_offset: FixedOffset::east_opt(0).unwrap(),
74            title: None,
75            line_width: 1,
76        }
77    }
78}
79
80impl PlotStyle {
81    /// Set the pixel dimensions of the plot (width, height). Default is 1920x1200.
82    pub fn with_drawing_area(mut self, drawing_area: impl Into<(u32, u32)>) -> Self {
83        self.drawing_area = drawing_area.into();
84        self
85    }
86
87    /// Override the title of the plot. By default, the title is derived from the metric name
88    /// and common labels.
89    pub fn with_title(mut self, title: impl Into<String>) -> Self {
90        self.title = Some(title.into());
91        self
92    }
93
94    /// Set the timezone offset for timestamp labels. Default is UTC.
95    /// Supports fractional-hour timezones like UTC+5:30.
96    pub fn with_utc_offset(mut self, offset: FixedOffset) -> Self {
97        self.utc_offset = offset;
98        self
99    }
100
101    /// Set the stroke width for data lines in pixels. Default is 1.
102    pub fn with_line_width(mut self, width: u32) -> Self {
103        self.line_width = width;
104        self
105    }
106
107    /// Set label names to exclude from the legend (e.g., "job", "instance").
108    pub fn with_skip_labels(mut self, labels: Vec<String>) -> Self {
109        self.skip_labels = labels;
110        self
111    }
112
113    /// Set the background color of the plot. Default is white.
114    pub fn with_background(mut self, color: impl Into<RGBAColor>) -> Self {
115        self.background = color.into();
116        self
117    }
118
119    /// Set the color of the grid lines. Default is semi-transparent gray.
120    pub fn with_grid_color(mut self, color: impl Into<RGBAColor>) -> Self {
121        self.grid = color.into();
122        self
123    }
124
125    /// Set the color of the axis lines. Default is black.
126    pub fn with_axis_color(mut self, color: impl Into<RGBAColor>) -> Self {
127        self.axis = color.into();
128        self
129    }
130
131    /// Set the color of axis labels and legend text. Default is black.
132    pub fn with_text_color(mut self, color: impl Into<RGBAColor>) -> Self {
133        self.text_color = color.into();
134        self
135    }
136
137    /// Set the font family for text. Default is "sans-serif".
138    pub fn with_text_font(mut self, font: impl Into<String>) -> Self {
139        self.text_font = font.into();
140        self
141    }
142
143    /// Set the font size for axis labels and legend in pixels. Default is 18.
144    pub fn with_text_size(mut self, size: u32) -> Self {
145        self.text_size = size;
146        self
147    }
148
149    /// Set the font size for the plot title/caption in pixels. Default is 36.
150    pub fn with_caption_size(mut self, size: u32) -> Self {
151        self.caption_size = size;
152        self
153    }
154
155    /// Set the colors to cycle through for data lines. If there are more series than colors,
156    /// colors will repeat.
157    pub fn with_data_colors(mut self, colors: Vec<RGBAColor>) -> Self {
158        self.data_colors = colors;
159        self
160    }
161
162    /// Set the color used to shade the threshold region in alert plots. Default is
163    /// semi-transparent red.
164    pub fn with_threshold_color(mut self, color: impl Into<RGBAColor>) -> Self {
165        self.threshold_color = color.into();
166        self
167    }
168}
169
170/// A shaded region appearing on the plot to indicate values that would trigger an alert
171pub enum PlotThreshold {
172    /// Shade values greater than this threshold
173    GreaterThan(f64),
174    /// Shade values less than this threshold
175    LessThan(f64),
176}
177
178impl PlotStyle {
179    /// Switch to a dark color scheme: black background with white text and axes.
180    pub fn dark_mode(mut self) -> Self {
181        self.background = BLACK.into();
182        self.grid = RGBAColor(100, 100, 100, 0.5);
183        self.axis = WHITE.into();
184        self.text_color = WHITE.into();
185        self
186    }
187
188    /// Plot a collection of metric timeseries data from prometheus, and possibly a "threshold" defined in an alert.
189    /// Write the result to a path. The file extension of the path will determine the format, e.g. png, gif, etc.
190    pub fn plot_timeseries<KV, K, V>(
191        &self,
192        path: impl AsRef<Path>,
193        mts: &[MetricTimeseries<KV>],
194        plot_threshold: Option<PlotThreshold>,
195    ) -> Result<(), Box<dyn Error + Send + Sync>>
196    where
197        KV: Clone + Debug,
198        K: Display,
199        V: Display,
200        for<'a> &'a KV: IntoIterator<Item = (&'a K, &'a V)>,
201    {
202        // Prepare to plot by scanning the data, finding x and y bounds, common labels, etc.
203        let ExtractLabels {
204            name,
205            common_labels,
206            specific_labels,
207        } = ExtractLabels::new(mts.iter().map(|mts| &mts.metric), &self.skip_labels);
208        let PreparedPlot {
209            x_range,
210            y_range,
211            ts,
212        } = PreparedPlot::prepare(mts)?;
213
214        // Figure out the caption for formatting style for date-times
215        // Use title override if provided, otherwise use extracted metric name
216        // Only append common_labels if no title override (since title likely already has labels)
217        let mut caption = if let Some(title) = &self.title {
218            title.clone()
219        } else {
220            let mut c = name;
221            if !common_labels.is_empty() {
222                write!(&mut c, " {common_labels:?}")?;
223            }
224            c
225        };
226
227        // Format date-times differently depending on the range of date-times being displayed.
228        //
229        // If they are all on the same day, then omit the day, and put it in the caption instead
230        let start_date_naive = x_range.start.with_timezone(&self.utc_offset).date_naive();
231        let date_format_str =
232            if start_date_naive == x_range.end.with_timezone(&self.utc_offset).date_naive() {
233                write!(&mut caption, " {start_date_naive}")?;
234                "%H:%M:%S"
235            } else {
236                "%m/%d %H:%M:%S"
237            };
238
239        // Add timezone offset to the caption (FixedOffset displays as +HH:MM or -HH:MM)
240        write!(&mut caption, " UTC{}", self.utc_offset)?;
241
242        // Actually start writing the file
243        let root_area = BitMapBackend::new(&path, self.drawing_area).into_drawing_area();
244        root_area.fill(&self.background)?;
245
246        let mut ctx = ChartBuilder::on(&root_area)
247            .set_label_area_size(LabelAreaPosition::Left, 100)
248            .set_label_area_size(LabelAreaPosition::Bottom, 40)
249            .caption(
250                caption,
251                (self.text_font.as_str(), self.caption_size, &self.text_color)
252                    .into_text_style(&root_area),
253            )
254            .build_cartesian_2d(x_range.clone(), y_range.clone())?;
255
256        let text_style =
257            (self.text_font.as_str(), self.text_size, &self.text_color).into_text_style(&root_area);
258
259        ctx.configure_mesh()
260            .light_line_style(self.grid) // Dark gray grid lines
261            .axis_style(self.axis) // White axis lines
262            .bold_line_style(self.axis) // White bold lines
263            .label_style(text_style.clone())
264            .x_label_formatter(&|x| {
265                x.with_timezone(&self.utc_offset)
266                    .format(date_format_str)
267                    .to_string()
268            })
269            .draw()?;
270
271        if let Some(threshold) = plot_threshold {
272            let (limit, baseline) = match threshold {
273                PlotThreshold::GreaterThan(limit) => (limit, y_range.end),
274                PlotThreshold::LessThan(limit) => (limit, y_range.start),
275            };
276            ctx.draw_series(AreaSeries::new(
277                [(x_range.start, limit), (x_range.end, limit)],
278                baseline,
279                self.threshold_color,
280            ))?;
281        }
282
283        // Only show legend if there are multiple series (single series doesn't need a legend)
284        let show_legend = specific_labels.len() > 1;
285
286        for (idx, (mut metric, vals)) in specific_labels.into_iter().zip(ts.into_iter()).enumerate()
287        {
288            let color = &self.data_colors[idx % self.data_colors.len()];
289            let style = ShapeStyle::from(color).stroke_width(self.line_width);
290
291            let name = metric.remove("__name__").unwrap_or_default();
292            let label = format!("{name} {metric:?}");
293
294            let series = ctx.draw_series(LineSeries::new(vals, style))?;
295            if show_legend {
296                series
297                    .label(label)
298                    .legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], style));
299            }
300        }
301
302        if show_legend {
303            ctx.configure_series_labels()
304                .position(SeriesLabelPosition::LowerLeft)
305                .border_style(self.axis)
306                .background_style(self.background.mix(0.8))
307                .label_font(text_style)
308                .draw()?;
309        }
310
311        // Signal any errors that occurred when writing the file
312        // https://github.com/plotters-rs/plotters?tab=readme-ov-file#faq-list
313        root_area.present()?;
314
315        Ok(())
316    }
317}
318
319struct PreparedPlot {
320    x_range: Range<DateTime<Utc>>,
321    y_range: Range<f64>,
322    ts: Vec<Vec<(DateTime<Utc>, f64)>>,
323}
324
325impl PreparedPlot {
326    fn prepare<KV>(data: &[impl Borrow<MetricTimeseries<KV>>]) -> Result<Self, &'static str>
327    where
328        KV: Clone + Debug,
329    {
330        let mut x_range = None;
331        let mut y_range = None;
332
333        let ts: Vec<Vec<(_, f64)>> = data
334            .iter()
335            .map(|mts| {
336                let mts = mts.borrow();
337
338                mts.values
339                    .iter()
340                    .filter_map(|(k, v)| {
341                        let x = f64_to_datetime(k)?;
342                        let y = *v.as_ref();
343                        extend_range(&mut x_range, &x);
344                        extend_range(&mut y_range, &y);
345
346                        Some((x, y))
347                    })
348                    .collect::<Vec<_>>()
349            })
350            .collect();
351
352        let x_range = x_range.ok_or("No data")?;
353        let y_range = y_range.ok_or("No data")?;
354
355        Ok(Self {
356            x_range,
357            y_range,
358            ts,
359        })
360    }
361}
362
363fn f64_to_datetime(t: &f64) -> Option<DateTime<Utc>> {
364    let seconds = t.trunc() as i64;
365    let nanoseconds = (t.fract() * 1_000_000_000.0) as u32;
366
367    Utc.timestamp_opt(seconds, nanoseconds).single()
368}
369
370fn extend_range<T: PartialOrd + Clone>(range: &mut Option<Range<T>>, val: &T) {
371    if let Some(range) = range.as_mut() {
372        if range.start > *val {
373            range.start = val.clone();
374        } else if range.end < *val {
375            range.end = val.clone();
376        }
377    } else {
378        *range = Some(val.clone()..val.clone())
379    }
380}