Skip to main content

tracing_durations_export/
plot.rs

1//! Visualize the spans and save the plot as svg.
2
3use std::collections::hash_map::Entry;
4use std::collections::{HashMap, HashSet};
5use std::time::Duration;
6
7use itertools::Itertools;
8use rustc_hash::FxHashMap;
9use serde::Deserialize;
10use svg::node::element::{Rectangle, Style, Text, Title, SVG};
11use svg::Document;
12
13const PLOT_STYLE: &str = r#"
14:root {
15  color-scheme: light dark;
16  --plot-background: #ffffff;
17  --plot-foreground: #111827;
18}
19
20@media (prefers-color-scheme: dark) {
21  :root {
22    --plot-background: #111827;
23    --plot-foreground: #e5e7eb;
24  }
25}
26
27.plot-background {
28  fill: var(--plot-background);
29}
30
31text {
32  fill: var(--plot-foreground);
33}
34"#;
35
36/// Owned type for deserialization.
37#[derive(Deserialize, Clone)]
38pub struct OwnedSpanInfo {
39    pub id: u64,
40    pub name: String,
41    pub start: Duration,
42    pub end: Duration,
43    #[allow(dead_code)]
44    pub parents: Option<Vec<u64>>,
45    pub is_main_thread: bool,
46    pub fields: Option<HashMap<String, String>>,
47}
48
49impl OwnedSpanInfo {
50    fn secs(&self) -> f32 {
51        (self.end - self.start).as_secs_f32()
52    }
53}
54
55/// Common visualization options.
56#[derive(Debug, Clone)]
57pub struct PlotConfig {
58    /// Don't overlay bottom spans.
59    pub multi_lane: bool,
60    /// Remove spans shorter than this.
61    pub min_length: Option<Duration>,
62    /// Remove spans with this name.
63    pub remove: Option<HashSet<String>>,
64    /// If the is only one field, display its value inline.
65    ///
66    /// Since the text is not limited to its box, text can overlap and become unreadable.
67    pub inline_field: bool,
68    /// The color for the plots in the active region, when running on the main thread. Default: semi-transparent orange
69    pub color_top_blocking: String,
70    /// The color for the plots in the active region, when the work offloaded from the main thread (with
71    /// `tokio::task::spawn_blocking`. Default: semi-transparent green
72    pub color_top_threadpool: String,
73    /// The color for the plots in the total region. Default: semi-transparent blue
74    pub color_bottom: String,
75    /// Do not draw the active regions.
76    pub skip_top: bool,
77    /// Do not draw the total durations regions.
78    pub skip_bottom: bool,
79}
80
81impl Default for PlotConfig {
82    fn default() -> Self {
83        PlotConfig {
84            multi_lane: false,
85            min_length: None,
86            remove: None,
87            inline_field: false,
88            // See http://www.cookbook-r.com/Graphs/Colors_(ggplot2)/#a-colorblind-friendly-palette
89            color_top_blocking: "#E69F0088".to_string(),
90            color_top_threadpool: "#009E7388".to_string(),
91            color_bottom: "#56B4E988".to_string(),
92            skip_top: false,
93            skip_bottom: false,
94        }
95    }
96}
97
98/// The dimensions of each part of the plot.
99#[derive(Debug, Clone)]
100pub struct PlotLayout {
101    /// Padding top for the entire svg.
102    pub padding_top: usize,
103    /// Padding bottom for the entire svg.
104    pub padding_bottom: usize,
105    /// Padding left for the entire svg.
106    pub padding_left: usize,
107    /// Padding right for the entire svg.
108    pub padding_right: usize,
109    /// The width of the text column on the left.
110    pub text_col_width: usize,
111    /// The of the bar plot section on the entire middle-right.
112    pub content_col_width: usize,
113    /// The height of each of the bars.
114    pub bar_height: usize,
115    /// In expanded mode, this much space is between the tracks.
116    pub multi_lane_padding: usize,
117    /// The padding between different kinds of spans.
118    pub section_padding_height: usize,
119}
120
121impl Default for PlotLayout {
122    fn default() -> Self {
123        PlotLayout {
124            padding_top: 5,
125            padding_bottom: 5,
126            padding_left: 5,
127            padding_right: 5,
128            text_col_width: 250,
129            content_col_width: 850,
130            bar_height: 20,
131            multi_lane_padding: 1,
132            section_padding_height: 10,
133        }
134    }
135}
136
137/// Visualize the spans.
138///
139/// You can store the result with `svg::save(plot_file, &svg)`.
140pub fn plot(
141    spans: &[OwnedSpanInfo],
142    end: Duration,
143    config: &PlotConfig,
144    layout: &PlotLayout,
145) -> SVG {
146    // TODO(konstin): Cow or move out of this method?
147    let spans = if let Some(remove) = &config.remove {
148        spans
149            .iter()
150            .filter(|span| !remove.contains(&span.name))
151            .cloned()
152            .collect::<Vec<_>>()
153    } else {
154        spans.to_vec()
155    };
156
157    // Spans can enter and exit multiple times, so we need to collect the duration from first start
158    // to last end to get the full span duration.
159    let mut full_spans: FxHashMap<u64, OwnedSpanInfo> = FxHashMap::default();
160    for span in &spans {
161        // These are in order because a span is emitted when it exits and exit must happen before
162        // re-entry
163        full_spans.entry(span.id).or_insert(span.clone()).end = span.end;
164    }
165
166    // Remove too short spans
167    // TODO(konstin): Again, copy on write?
168    let (spans, full_spans) = if let Some(min_length) = config.min_length {
169        let mut removed_ids = HashSet::new();
170        for (id, full_span) in &full_spans {
171            if full_span.end - full_span.start < min_length {
172                removed_ids.insert(*id);
173            }
174        }
175        let spans = spans
176            .iter()
177            .filter(|span| !removed_ids.contains(&span.id))
178            .cloned()
179            .collect::<Vec<_>>();
180        for removed_id in removed_ids {
181            full_spans.remove(&removed_id);
182        }
183        (spans, full_spans)
184    } else {
185        (spans.to_vec(), full_spans)
186    };
187
188    let mut earliest_starts: FxHashMap<&str, Duration> = FxHashMap::default();
189    for span in &spans {
190        // For the left sidebar, sort spans by the first time a span name occurred
191        match earliest_starts.entry(&span.name) {
192            Entry::Occupied(mut entry) => {
193                if entry.get() > &span.start {
194                    entry.insert(span.start);
195                }
196            }
197            Entry::Vacant(entry) => {
198                entry.insert(span.start);
199            }
200        }
201    }
202
203    // In expanded mode, we avoid overlaps in different lanes, so we track
204    // until which timestamp each lane is blocked and how many lanes we need.
205    let mut lanes_end: HashMap<&str, Vec<Duration>> = HashMap::new();
206    let mut span_lanes = HashMap::new();
207    let mut full_spans_sorted: Vec<_> = full_spans.values().collect();
208    full_spans_sorted.sort_by_key(|span| span.start);
209    for full_span in full_spans_sorted {
210        if config.multi_lane {
211            let lanes = lanes_end.entry(&full_span.name).or_default();
212            if let Some((idx, lane_end)) = lanes
213                .iter_mut()
214                .enumerate()
215                .find(|(_idx, end)| &full_span.start > end)
216            {
217                span_lanes.insert(full_span.id, idx);
218                *lane_end = full_span.end;
219            } else {
220                span_lanes.insert(full_span.id, lanes.len());
221                lanes.push(full_span.end)
222            }
223        } else {
224            span_lanes.insert(full_span.id, 0);
225            lanes_end
226                .entry(&full_span.name)
227                .or_insert_with(|| vec![full_span.end])[0] = full_span.end;
228        }
229    }
230
231    let extra_lane_height = layout.bar_height / 2 + layout.multi_lane_padding;
232
233    let mut earliest_starts: Vec<_> = earliest_starts.into_iter().collect();
234    earliest_starts.sort_by_key(|(_name, duration)| *duration);
235    let name_offsets: FxHashMap<&str, usize> = earliest_starts
236        .iter()
237        .enumerate()
238        // Add an empty line for the timeline
239        .map(|(idx, (name, _earliest_start))| (*name, idx + 1))
240        .collect();
241
242    // TODO(konstin): Functional version?
243    let mut extra_lanes_cur = 0;
244    let mut extra_lanes_cumulative = HashMap::new();
245    for (name, _start) in earliest_starts {
246        extra_lanes_cumulative.insert(name, extra_lanes_cur);
247        extra_lanes_cur += lanes_end.get(name).map_or(0, |lanes| lanes.len() - 1);
248    }
249
250    let total_width = layout.padding_left
251        + layout.text_col_width
252        + layout.content_col_width
253        + layout.padding_right;
254    // Don't forget the timeline row
255    let total_height = layout.padding_top
256        + (layout.bar_height + layout.section_padding_height) * (name_offsets.len() + 1)
257        + extra_lane_height * extra_lanes_cur
258        + layout.padding_bottom;
259
260    let mut document = Document::new()
261        .set("width", total_width)
262        .set("height", total_height)
263        .set("viewBox", (0, 0, total_width, total_height))
264        .add(Style::new(PLOT_STYLE))
265        .add(
266            Rectangle::new()
267                .set("class", "plot-background")
268                .set("x", 0)
269                .set("y", 0)
270                .set("width", total_width)
271                .set("height", total_height)
272                .set("fill", "#ffffff"),
273        );
274
275    // Add the "timeline" of start and stop time.
276    document = document
277        .add(
278            Text::new("0s")
279                .set("x", layout.text_col_width)
280                .set("y", layout.padding_top + layout.bar_height / 2)
281                .set("dominant-baseline", "middle")
282                .set("text-anchor", "start"),
283        )
284        .add(
285            Text::new(format!("{:.3}s", end.as_secs_f32()))
286                .set("x", layout.text_col_width + layout.content_col_width)
287                .set("y", layout.padding_top + layout.bar_height / 2)
288                .set("dominant-baseline", "middle")
289                .set("text-anchor", "end"),
290        );
291
292    // Add a note about filtered out spans
293    if let Some(min_length) = config.min_length {
294        let text = format!("only spans >{}s", min_length.as_secs_f32());
295        document = document.add(
296            Text::new(text)
297                .set("x", layout.padding_left)
298                .set("y", layout.padding_top + layout.bar_height / 2)
299                .set("dominant-baseline", "middle")
300                .set("text-anchor", "start"),
301        );
302    }
303
304    // Draw the legend on the left
305    for (name, offset) in &name_offsets {
306        document = document.add(
307            Text::new(name.to_string())
308                .set("x", layout.padding_left)
309                .set(
310                    "y",
311                    layout.padding_top
312                        + layout.bar_height / 2
313                        + offset * (layout.bar_height + layout.section_padding_height)
314                        + extra_lane_height * extra_lanes_cumulative[name],
315                )
316                .set("dominant-baseline", "middle"),
317        );
318    }
319
320    let format_tooltip = |span: &OwnedSpanInfo| {
321        let fields = span
322            .fields
323            .iter()
324            .flatten()
325            .map(|(key, value)| format!("{key}: {value}"))
326            .join("\n");
327        format!("{} {:.3}s\n{}", span.name, span.secs(), fields)
328    };
329
330    // Draw the active top half of each span
331    if !config.skip_top {
332        for span in &spans {
333            let offset = name_offsets[span.name.as_str()];
334            let color = if span.is_main_thread {
335                config.color_top_blocking.clone()
336            } else {
337                config.color_top_threadpool.clone()
338            };
339            document = document.add(
340                Rectangle::new()
341                    .set(
342                        "x",
343                        layout.text_col_width as f32
344                            + layout.content_col_width as f32 * span.start.as_secs_f32()
345                                / end.as_secs_f32(),
346                    )
347                    .set(
348                        "y",
349                        offset * (layout.bar_height + layout.section_padding_height)
350                            + extra_lane_height * extra_lanes_cumulative[span.name.as_str()],
351                    )
352                    .set(
353                        "width",
354                        layout.content_col_width as f32 * span.secs() / end.as_secs_f32(),
355                    )
356                    .set("height", layout.bar_height / 2)
357                    .set("fill", color)
358                    // Add tooltip
359                    .add(Title::new(format_tooltip(span))),
360            )
361        }
362    }
363
364    // Draw the total bottom half of each span
365    if !config.skip_bottom {
366        for full_span in full_spans.values() {
367            let x = layout.text_col_width as f32
368                + layout.content_col_width as f32 * full_span.start.as_secs_f32()
369                    / end.as_secs_f32();
370            let y = name_offsets[full_span.name.as_str()]
371                * (layout.bar_height + layout.section_padding_height)
372                + extra_lane_height * extra_lanes_cumulative[full_span.name.as_str()]
373                + extra_lane_height * span_lanes[&full_span.id]
374                + layout.bar_height / 2;
375            let width = layout.content_col_width as f32
376                * (full_span.end - full_span.start).as_secs_f32()
377                / end.as_secs_f32();
378            let height = layout.bar_height / 2;
379            document = document.add(
380                Rectangle::new()
381                    .set("x", x)
382                    .set("y", y)
383                    .set("width", width)
384                    .set("height", height)
385                    .set("fill", config.color_bottom.to_string())
386                    // Add tooltip
387                    .add(Title::new(format_tooltip(full_span))),
388            );
389            let mut fields = full_span
390                .fields
391                .as_ref()
392                .map(|map| map.values())
393                .into_iter()
394                .flatten();
395            if let Some(value) = fields.next() {
396                if config.inline_field && fields.next().is_none() {
397                    document = document.add(
398                        Text::new(value)
399                            .set("x", x)
400                            .set("y", y + height / 2)
401                            .set("font-size", "0.7em")
402                            .set("dominant-baseline", "middle")
403                            .set("text-anchor", "start"),
404                    )
405                }
406            }
407        }
408    }
409
410    document
411}