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