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