Skip to main content

headless_lms_base/error/clean_format/
spans.rs

1/*!
2Render the tracing span trace as a compact breadcrumb.
3
4The span trace carries the instrumented spans active when the error was created plus
5their field values (request_id, course_id, ...). Rendered root-first as
6`http_request{request_id=…} › send_message{course_id=…}` for request context at a glance.
7*/
8
9use tracing_error::SpanTrace;
10
11/// Only include spans from our own instrumentation; runtime/library spans add noise.
12const OUR_TARGET_PREFIX: &str = "headless_lms";
13
14/// Maximum length of a single span's rendered field string before truncation.
15const MAX_FIELDS_LEN: usize = 80;
16
17/// One captured span reduced to its name and formatted fields.
18#[derive(Debug, Clone)]
19pub struct SpanEntry {
20    pub name: String,
21    pub fields: String,
22}
23
24/// Build the breadcrumb from root-first entries, or `None` if there is nothing to show.
25pub fn render_breadcrumb(entries: &[SpanEntry]) -> Option<String> {
26    if entries.is_empty() {
27        return None;
28    }
29    let parts: Vec<String> = entries
30        .iter()
31        .map(|entry| {
32            if entry.fields.is_empty() {
33                entry.name.clone()
34            } else {
35                format!("{}{{{}}}", entry.name, truncate(&entry.fields))
36            }
37        })
38        .collect();
39    Some(parts.join(" › "))
40}
41
42fn truncate(fields: &str) -> String {
43    if fields.chars().count() <= MAX_FIELDS_LEN {
44        return fields.to_string();
45    }
46    let cut: String = fields.chars().take(MAX_FIELDS_LEN).collect();
47    format!("{cut}…")
48}
49
50/// Extract our-instrumentation spans from a [`SpanTrace`], ordered root-first.
51pub fn extract_spans(span_trace: &SpanTrace) -> Vec<SpanEntry> {
52    let mut entries = Vec::new();
53    span_trace.with_spans(|metadata, fields| {
54        if metadata.target().starts_with(OUR_TARGET_PREFIX) {
55            entries.push(SpanEntry {
56                name: metadata.name().to_string(),
57                fields: fields.to_string(),
58            });
59        }
60        true
61    });
62    // `with_spans` visits innermost-first; we want root-first for the breadcrumb.
63    entries.reverse();
64    entries
65}
66
67/// Extract and render a span trace's breadcrumb.
68pub fn breadcrumb(span_trace: &SpanTrace) -> Option<String> {
69    render_breadcrumb(&extract_spans(span_trace))
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    fn entry(name: &str, fields: &str) -> SpanEntry {
77        SpanEntry {
78            name: name.to_string(),
79            fields: fields.to_string(),
80        }
81    }
82
83    #[test]
84    fn empty_yields_none() {
85        assert_eq!(render_breadcrumb(&[]), None);
86    }
87
88    #[test]
89    fn joins_root_first_with_fields() {
90        let entries = vec![
91            entry("http_request", "request_id=aea0"),
92            entry("send_message", "course_id=5d79"),
93        ];
94        assert_eq!(
95            render_breadcrumb(&entries).unwrap(),
96            "http_request{request_id=aea0} › send_message{course_id=5d79}"
97        );
98    }
99
100    #[test]
101    fn span_without_fields_shows_bare_name() {
102        assert_eq!(render_breadcrumb(&[entry("root", "")]).unwrap(), "root");
103    }
104
105    #[test]
106    fn long_fields_are_truncated() {
107        let long = "x".repeat(MAX_FIELDS_LEN + 20);
108        let rendered = render_breadcrumb(&[entry("s", &long)]).unwrap();
109        // The ellipsis sits inside the `{…}` wrapper, so the whole string ends with `}`.
110        assert!(rendered.contains('…'), "got: {rendered}");
111        assert!(rendered.ends_with("…}"), "got: {rendered}");
112        assert!(rendered.chars().count() < long.chars().count() + "s{}".len());
113    }
114}