Skip to main content

rustlavel_debugbar/
collector.rs

1//! Gathering what one request did, and only that request.
2//!
3//! The instrumentation bus is global: every query in the process dispatches to
4//! the same subscribers. A debug bar needs the opposite — the queries *this*
5//! request made, so the number on the page belongs to the page.
6//!
7//! A task-local does that. Every request is a task, everything the handler
8//! awaits stays on it, and so a collector installed for the task sees that
9//! request's events and nobody else's. Work moved off the task with
10//! `tokio::spawn` is not collected, which is the honest limit of the approach
11//! and is stated on the bar rather than hidden.
12
13use rustlavel_core::events::Event;
14use rustlavel_core::Json;
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18tokio::task_local! {
19    /// The collector for the request running on this task, if the bar is on.
20    static CURRENT: Arc<Mutex<Collected>>;
21}
22
23/// One thing that happened, in the order it happened.
24#[derive(Debug, Clone)]
25pub struct Timing {
26    pub kind: String,
27    /// The line shown on the bar — SQL, a cache key, a model name.
28    pub label: String,
29    pub duration: Option<Duration>,
30    /// Everything else the event carried, already redacted.
31    pub fields: Vec<(String, String)>,
32}
33
34/// Everything collected for one request.
35#[derive(Debug, Default, Clone)]
36pub struct Collected {
37    pub timings: Vec<Timing>,
38    pub started: Option<Instant>,
39}
40
41impl Collected {
42    /// Events of one kind, in order.
43    pub fn of(&self, kind: &str) -> Vec<&Timing> {
44        self.timings.iter().filter(|timing| timing.kind == kind).collect()
45    }
46
47    pub fn total(&self, kind: &str) -> Duration {
48        self.of(kind).iter().filter_map(|timing| timing.duration).sum()
49    }
50
51    /// Queries whose text repeats, and how many times.
52    ///
53    /// This is the N+1 detector, and it is the reason a bar is worth more than
54    /// a total. "31 queries, 12ms" looks fine; "the same query 30 times" is a
55    /// missing eager load, and only the second one tells you what to do.
56    pub fn repeated_queries(&self) -> Vec<(String, usize)> {
57        let mut counts: Vec<(String, usize)> = Vec::new();
58
59        for query in self.of("db.query") {
60            match counts.iter_mut().find(|(sql, _)| *sql == query.label) {
61                Some((_, count)) => *count += 1,
62                None => counts.push((query.label.clone(), 1)),
63            }
64        }
65
66        counts.retain(|(_, count)| *count > 1);
67        // Most repeated first: that is the one worth fixing.
68        counts.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
69        counts
70    }
71}
72
73/// Installs itself on the bus and routes events to whichever request is running.
74pub struct Collector;
75
76impl Collector {
77    /// Subscribe once, for the life of the process.
78    ///
79    /// Every dispatched event reaches this, and it drops the ones arriving on a
80    /// task with no bar — a background job, a queue worker, anything that is not
81    /// a request being rendered.
82    ///
83    /// Idempotent, and it has to be. Subscribing twice does not collect twice
84    /// as carefully; it records every query twice, and the bar then reports a
85    /// page making double the queries it makes. A plugin registered in two
86    /// places, or a test suite calling this per test, would both do it.
87    pub fn install() {
88        static ONCE: std::sync::Once = std::sync::Once::new();
89        ONCE.call_once(|| {
90        rustlavel_core::events::subscribe(|event: &Event| {
91            let _ = CURRENT.try_with(|collected| {
92                // A poisoned lock means a panic happened mid-push. Losing one
93                // row of a debug bar is not worth a second panic on top.
94                if let Ok(mut collected) = collected.lock() {
95                    collected.timings.push(timing_from(event));
96                }
97            });
98        });
99        });
100    }
101
102    /// Run `work` with a collector installed, and return what it gathered.
103    pub async fn collect<F, T>(work: F) -> (T, Collected)
104    where
105        F: std::future::Future<Output = T>,
106    {
107        let collected = Arc::new(Mutex::new(Collected {
108            timings: Vec::new(),
109            started: Some(Instant::now()),
110        }));
111        let handle = Arc::clone(&collected);
112
113        let result = CURRENT.scope(collected, work).await;
114        let gathered = handle
115            .lock()
116            .map(|inner| inner.clone())
117            .unwrap_or_else(|poisoned| poisoned.into_inner().clone());
118        (result, gathered)
119    }
120}
121
122/// The fields worth showing for a kind of event, in the order to show them.
123fn label_for(event: &Event) -> String {
124    let field = |name: &str| event.field(name).and_then(Json::as_str).unwrap_or("").to_string();
125
126    match event.kind {
127        "db.query" => field("sql"),
128        "cache.hit" | "cache.miss" => field("key"),
129        "http.client" => format!("{} {}", field("method"), field("url")),
130        "mail.sent" => field("to"),
131        "ai.call" => format!("{} {}", field("provider"), field("model")),
132        "mcp.call" => field("tool"),
133        "queue.pushed" | "queue.processed" | "queue.failed" => field("job"),
134        _ => event
135            .fields
136            .iter()
137            .next()
138            .map(|(key, value)| format!("{key}={}", brief(value)))
139            .unwrap_or_default(),
140    }
141}
142
143fn timing_from(event: &Event) -> Timing {
144    Timing {
145        kind: event.kind.to_string(),
146        label: label_for(event),
147        duration: event.duration,
148        fields: event
149            .fields
150            .iter()
151            .filter(|(key, _)| !is_sensitive(key))
152            .map(|(key, value)| (key.clone(), brief(value)))
153            .collect(),
154    }
155}
156
157/// A field name that must never reach the page.
158///
159/// The bar is written into HTML, which lands in a browser, a screenshot and a
160/// bug report. Query bindings are already withheld in production by the
161/// database package; this is the second line, and it is deliberately blunt
162/// rather than clever.
163fn is_sensitive(key: &str) -> bool {
164    const MARKERS: &[&str] = &[
165        "password", "passwd", "secret", "token", "authorization", "api_key", "apikey",
166        "api-key", "credential", "private_key", "session", "cookie",
167    ];
168    let lowered = key.to_ascii_lowercase();
169    MARKERS.iter().any(|marker| lowered.contains(marker))
170}
171
172/// A value short enough for one row of a bar.
173fn brief(value: &Json) -> String {
174    let text = match value {
175        Json::String(text) => text.clone(),
176        other => other.to_string(),
177    };
178    if text.chars().count() > 200 {
179        format!("{}…", text.chars().take(200).collect::<String>())
180    } else {
181        text
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn query(sql: &str, ms: u64) -> Timing {
190        Timing {
191            kind: "db.query".into(),
192            label: sql.into(),
193            duration: Some(Duration::from_millis(ms)),
194            fields: Vec::new(),
195        }
196    }
197
198    #[test]
199    fn totals_only_the_kind_asked_for() {
200        let collected = Collected {
201            timings: vec![
202                query("select 1", 3),
203                query("select 2", 4),
204                Timing {
205                    kind: "cache.hit".into(),
206                    label: "users:1".into(),
207                    duration: Some(Duration::from_millis(50)),
208                    fields: Vec::new(),
209                },
210            ],
211            started: None,
212        };
213
214        assert_eq!(collected.of("db.query").len(), 2);
215        assert_eq!(collected.total("db.query"), Duration::from_millis(7));
216        assert_eq!(collected.total("cache.hit"), Duration::from_millis(50));
217    }
218
219    #[test]
220    fn finds_the_query_that_repeats() {
221        // The whole reason a bar beats a total. Thirty of these is a missing
222        // eager load; the count alone would just say "31 queries".
223        let mut timings = vec![query("select * from posts", 2)];
224        for _ in 0..30 {
225            timings.push(query("select * from users where id = ?", 1));
226        }
227        let collected = Collected { timings, started: None };
228
229        let repeated = collected.repeated_queries();
230        assert_eq!(repeated.len(), 1, "only the repeated one is reported");
231        assert_eq!(repeated[0], ("select * from users where id = ?".to_string(), 30));
232    }
233
234    #[test]
235    fn a_query_run_once_is_not_reported_as_repeated() {
236        let collected =
237            Collected { timings: vec![query("select 1", 1), query("select 2", 1)], started: None };
238
239        assert!(collected.repeated_queries().is_empty());
240    }
241
242    #[test]
243    fn sensitive_field_names_never_reach_the_page() {
244        // The bar is written into HTML that ends up in screenshots and bug
245        // reports, so this is bluntly a list of names rather than anything
246        // clever about values.
247        for key in [
248            "password", "PASSWORD", "api_key", "authorization", "x-api-key",
249            "session_id", "cookie", "db_credential", "private_key",
250        ] {
251            assert!(is_sensitive(key), "{key} should be withheld");
252        }
253        for key in ["sql", "rows", "duration_ms", "route", "status", "key"] {
254            assert!(!is_sensitive(key), "{key} is not sensitive and is worth showing");
255        }
256    }
257
258    #[test]
259    fn a_long_value_is_cut_rather_than_filling_the_page() {
260        let long = Json::String("x".repeat(5_000));
261        let shown = brief(&long);
262
263        assert!(shown.chars().count() <= 201, "got {} characters", shown.chars().count());
264        assert!(shown.ends_with('…'));
265    }
266
267    #[tokio::test]
268    async fn collects_what_happened_inside_the_scope_and_nothing_outside_it() {
269        Collector::install();
270
271        // Dispatched before any collector exists: belongs to nobody.
272        Event::new("db.query").with("sql", "select 'before'").dispatch();
273
274        let ((), collected) = Collector::collect(async {
275            Event::new("db.query").with("sql", "select 'inside'").dispatch();
276            Event::new("cache.hit").with("key", "users:1").dispatch();
277        })
278        .await;
279
280        Event::new("db.query").with("sql", "select 'after'").dispatch();
281
282        let queries = collected.of("db.query");
283        assert_eq!(queries.len(), 1, "only the query inside the scope");
284        assert_eq!(queries[0].label, "select 'inside'");
285        assert_eq!(collected.of("cache.hit").len(), 1);
286    }
287
288    #[tokio::test]
289    async fn two_requests_do_not_see_each_other() {
290        // The property the whole design rests on: a number on one page must
291        // belong to that page. The bus is global; the collector is not.
292        Collector::install();
293
294        let first = Collector::collect(async {
295            Event::new("db.query").with("sql", "first").dispatch();
296            tokio::task::yield_now().await;
297            Event::new("db.query").with("sql", "first again").dispatch();
298        });
299
300        let second = Collector::collect(async {
301            Event::new("db.query").with("sql", "second").dispatch();
302            tokio::task::yield_now().await;
303        });
304
305        let (((), one), ((), two)) = tokio::join!(first, second);
306
307        assert_eq!(one.of("db.query").len(), 2);
308        assert_eq!(two.of("db.query").len(), 1);
309        assert_eq!(two.of("db.query")[0].label, "second");
310    }
311}