Skip to main content

rustlavel_debugbar/
render.rs

1//! Turning what was collected into the bar itself.
2//!
3//! One string of HTML with its own styles and script inlined. No asset to
4//! serve, no build step, nothing to keep in sync with a version number — a
5//! debug bar that needs its own pipeline is one that stops working the first
6//! time somebody upgrades something.
7
8use crate::collector::Collected;
9use std::time::Duration;
10
11/// Build the bar for one request.
12pub fn bar(collected: &Collected, route: &str, status: u16) -> String {
13    let elapsed = collected.started.map(|started| started.elapsed()).unwrap_or_default();
14    let queries = collected.of("db.query");
15    let repeated = collected.repeated_queries();
16
17    let mut html = String::with_capacity(4096);
18    html.push_str(r#"<div id="rl-bar" data-rl-bar><style>"#);
19    html.push_str(STYLE);
20    html.push_str("</style>");
21
22    html.push_str(r#"<div class="rl-summary" onclick="rlToggle()">"#);
23    push_chip(&mut html, "route", &format!("{status} {route}"), false);
24    push_chip(&mut html, "time", &millis(elapsed), false);
25    push_chip(
26        &mut html,
27        "queries",
28        &format!("{} · {}", queries.len(), millis(collected.total("db.query"))),
29        // The count turns red when the same query repeats, because that is the
30        // one number a person should act on rather than merely read.
31        !repeated.is_empty(),
32    );
33
34    let hits = collected.of("cache.hit").len();
35    let misses = collected.of("cache.miss").len();
36    if hits + misses > 0 {
37        push_chip(&mut html, "cache", &format!("{hits} hit · {misses} miss"), false);
38    }
39    html.push_str(r#"<span class="rl-grow"></span><span class="rl-caret">▲</span></div>"#);
40
41    html.push_str(r#"<div class="rl-panel" hidden>"#);
42
43    if !repeated.is_empty() {
44        html.push_str(r#"<div class="rl-warn"><strong>Repeated queries.</strong> "#);
45        html.push_str("The same statement ran more than once, which is usually a relation \
46                       loaded inside a loop rather than alongside the set.</div>");
47        html.push_str("<table>");
48        for (sql, count) in repeated.iter().take(10) {
49            html.push_str(&format!(
50                "<tr><td class=\"rl-count\">×{count}</td><td><code>{}</code></td></tr>",
51                escape(sql)
52            ));
53        }
54        html.push_str("</table>");
55    }
56
57    for (kind, heading) in SECTIONS {
58        let entries = collected.of(kind);
59        if entries.is_empty() {
60            continue;
61        }
62        html.push_str(&format!("<h4>{heading} <span>{}</span></h4><table>", entries.len()));
63        for entry in entries.iter().take(100) {
64            html.push_str("<tr><td class=\"rl-dur\">");
65            html.push_str(&entry.duration.map(millis).unwrap_or_else(|| "—".into()));
66            html.push_str("</td><td><code>");
67            html.push_str(&escape(&entry.label));
68            html.push_str("</code></td></tr>");
69        }
70        html.push_str("</table>");
71    }
72
73    html.push_str(
74        r#"<p class="rl-note">Work moved to another task with <code>tokio::spawn</code> is
75        not counted here — it is no longer on the request's task, which is what the bar
76        follows.</p>"#,
77    );
78    html.push_str("</div>");
79    html.push_str(r#"<script>function rlToggle(){var p=document.querySelector('#rl-bar .rl-panel');p.hidden=!p.hidden;document.querySelector('#rl-bar .rl-caret').textContent=p.hidden?'▲':'▼';}</script>"#);
80    html.push_str("</div>");
81    html
82}
83
84const SECTIONS: &[(&str, &str)] = &[
85    ("db.query", "Queries"),
86    ("cache.hit", "Cache hits"),
87    ("cache.miss", "Cache misses"),
88    ("http.client", "Outbound HTTP"),
89    ("ai.call", "AI"),
90    ("mcp.call", "MCP"),
91    ("mail.sent", "Mail"),
92    ("queue.pushed", "Jobs queued"),
93];
94
95fn push_chip(html: &mut String, label: &str, value: &str, alarming: bool) {
96    let class = if alarming { "rl-chip rl-alarm" } else { "rl-chip" };
97    html.push_str(&format!(
98        r#"<span class="{class}"><b>{}</b>{}</span>"#,
99        escape(label),
100        escape(value)
101    ));
102}
103
104fn millis(duration: Duration) -> String {
105    let ms = duration.as_secs_f64() * 1000.0;
106    if ms < 1.0 { format!("{:.2} ms", ms) } else { format!("{:.1} ms", ms) }
107}
108
109/// Escape for HTML text.
110///
111/// Everything here — SQL, cache keys, a route — can contain characters a user
112/// put there. A debug bar that renders them raw is a cross-site scripting hole
113/// in the one tool you are staring at while you work.
114fn escape(text: &str) -> String {
115    let mut out = String::with_capacity(text.len());
116    for character in text.chars() {
117        match character {
118            '&' => out.push_str("&amp;"),
119            '<' => out.push_str("&lt;"),
120            '>' => out.push_str("&gt;"),
121            '"' => out.push_str("&quot;"),
122            '\'' => out.push_str("&#39;"),
123            other => out.push(other),
124        }
125    }
126    out
127}
128
129const STYLE: &str = "#rl-bar{position:fixed;left:0;right:0;bottom:0;z-index:2147483000;\
130font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e6e8ea;\
131background:#14181d;border-top:1px solid #2a313a;box-shadow:0 -2px 12px rgba(0,0,0,.3)}\
132#rl-bar .rl-summary{display:flex;align-items:center;gap:14px;padding:7px 12px;cursor:pointer}\
133#rl-bar .rl-chip{white-space:nowrap}#rl-bar .rl-chip b{color:#7b8794;font-weight:400;margin-right:6px}\
134#rl-bar .rl-alarm{color:#ff8a6a}#rl-bar .rl-grow{flex:1}#rl-bar .rl-caret{color:#7b8794}\
135#rl-bar .rl-panel{max-height:45vh;overflow:auto;border-top:1px solid #2a313a;padding:10px 12px}\
136#rl-bar h4{margin:14px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#7b8794}\
137#rl-bar h4 span{color:#4a5561}#rl-bar table{width:100%;border-collapse:collapse}\
138#rl-bar td{padding:3px 8px 3px 0;vertical-align:top;border-bottom:1px solid #1b2027}\
139#rl-bar .rl-dur,#rl-bar .rl-count{white-space:nowrap;color:#7b8794;width:1%}\
140#rl-bar code{color:#cfd6dd;word-break:break-all}\
141#rl-bar .rl-warn{background:#2a1d18;border-left:3px solid #ff8a6a;padding:8px 10px;margin:4px 0 8px}\
142#rl-bar .rl-note{color:#4a5561;margin:14px 0 0}";
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::collector::Timing;
148
149    fn query(sql: &str) -> Timing {
150        Timing {
151            kind: "db.query".into(),
152            label: sql.into(),
153            duration: Some(Duration::from_millis(2)),
154            fields: Vec::new(),
155        }
156    }
157
158    #[test]
159    fn shows_the_route_status_and_query_count() {
160        let collected =
161            Collected { timings: vec![query("select 1"), query("select 2")], started: None };
162        let html = bar(&collected, "/users/{id}", 200);
163
164        assert!(html.contains("200 /users/{id}"));
165        assert!(html.contains("2 · "), "the query count belongs on the summary");
166    }
167
168    #[test]
169    fn a_repeated_query_is_called_out_rather_than_just_counted() {
170        let mut timings = vec![];
171        for _ in 0..12 {
172            timings.push(query("select * from users where id = ?"));
173        }
174        let html = bar(&Collected { timings, started: None }, "/posts", 200);
175
176        assert!(html.contains("Repeated queries"));
177        assert!(html.contains("×12"));
178        assert!(html.contains("rl-alarm"), "the chip turns red so it is noticed");
179    }
180
181    #[test]
182    fn sql_containing_html_cannot_close_the_page_or_run_a_script() {
183        // A value a user typed reaches the bar through a query. Rendering it
184        // raw would be a scripting hole in the tool you stare at all day.
185        let hostile = "select * from t where name = '</script><img src=x onerror=alert(1)>'";
186        let html = bar(&Collected { timings: vec![query(hostile)], started: None }, "/", 200);
187
188        assert!(!html.contains("<img src=x"), "raw markup reached the page");
189        assert!(!html.contains("</script><img"), "the script tag was not closed early");
190        assert!(html.contains("&lt;img src=x"));
191    }
192
193    #[test]
194    fn a_hostile_route_is_escaped_too() {
195        let html = bar(&Collected::default(), "/<script>alert(1)</script>", 404);
196        assert!(!html.contains("<script>alert(1)"));
197        assert!(html.contains("&lt;script&gt;"));
198    }
199
200    #[test]
201    fn an_empty_request_still_renders_a_usable_bar() {
202        let html = bar(&Collected::default(), "/health", 204);
203
204        assert!(html.contains("204 /health"));
205        assert!(html.contains("rl-bar"));
206        assert!(!html.contains("Repeated queries"));
207    }
208
209    #[test]
210    fn the_limit_of_the_measurement_is_printed_rather_than_hidden() {
211        // Work spawned off the request task is genuinely not counted, and a
212        // number that quietly omits some of the work is worse than one that
213        // says what it left out.
214        let html = bar(&Collected::default(), "/", 200);
215        assert!(html.contains("tokio::spawn"));
216    }
217}