Skip to main content

rustlavel_telescope/
plugin.rs

1//! Turning Telescope on, and refusing to turn it on where it does not belong.
2//!
3//! ```ignore
4//! App::new()?
5//!     .routes(routes::web::routes)
6//!     .plugin(Telescope::default())
7//!     .serve()
8//!     .await
9//! ```
10//!
11//! One line, no auto-discovery, no service provider: the application says it
12//! wants Telescope and the plugin does the rest — subscribe to the bus, mount
13//! the dashboard, and register the store so the application's own handlers can
14//! read it back.
15
16use crate::dashboard::PageOptions;
17use crate::journal::Journal;
18use crate::recorder::Recorder;
19use crate::routes;
20use crate::store::{DEFAULT_CAPACITY, Store};
21use rustlavel_core::{Config, events};
22use rustlavel_http::plugin::{Plugin, Setup};
23use rustlavel_http::Router;
24use std::path::PathBuf;
25
26/// Where the dashboard lives when nothing says otherwise.
27pub const DEFAULT_MOUNT: &str = "/telescope";
28/// Entries at or above this many milliseconds are highlighted.
29pub const DEFAULT_SLOW_MS: f64 = 100.0;
30
31/// The Telescope plugin.
32///
33/// Every setting can come from configuration instead, so a deployment can tune
34/// it without recompiling. An explicit builder call always wins over
35/// configuration, because a value written in `main.rs` is a decision and a
36/// value in a config file is a default:
37///
38/// | Config key           | Meaning                                             |
39/// |----------------------|-----------------------------------------------------|
40/// | `telescope.enabled`  | Mount at all. Defaults to false in production.      |
41/// | `telescope.route`    | Where the dashboard is mounted (`/telescope`).      |
42/// | `telescope.limit`    | Ring buffer size (500 entries).                     |
43/// | `telescope.slow_ms`  | Duration at which an entry is marked slow (100).    |
44/// | `telescope.path`     | JSON-lines file to persist entries to. Off by default. |
45#[derive(Default)]
46pub struct Telescope {
47    mount: Option<String>,
48    capacity: Option<usize>,
49    slow_ms: Option<f64>,
50    file: Option<PathBuf>,
51    force: bool,
52    ignored: Vec<String>,
53}
54
55impl Telescope {
56    pub fn new() -> Self {
57        Telescope::default()
58    }
59
60    /// Mount the dashboard somewhere else: `.at("/_debug")`.
61    pub fn at(mut self, mount: impl Into<String>) -> Self {
62        self.mount = Some(mount.into());
63        self
64    }
65
66    /// How many entries to keep. Memory is the only cost, and an entry is small.
67    pub fn capacity(mut self, entries: usize) -> Self {
68        self.capacity = Some(entries);
69        self
70    }
71
72    /// The threshold above which an entry is highlighted as slow.
73    pub fn slow_after_ms(mut self, millis: f64) -> Self {
74        self.slow_ms = Some(millis);
75        self
76    }
77
78    /// Also append entries to a JSON-lines file, and load them back on boot, so
79    /// a restart does not lose the request you were about to look at.
80    pub fn persist_to(mut self, path: impl Into<PathBuf>) -> Self {
81        self.file = Some(path.into());
82        self
83    }
84
85    /// Do not record this kind of event.
86    pub fn ignore(mut self, kind: impl Into<String>) -> Self {
87        self.ignored.push(kind.into());
88        self
89    }
90
91    /// Mount even in production.
92    ///
93    /// Spelled out in full because of what it means: the dashboard shows SQL,
94    /// log messages, and every field any package chose to emit. Anyone who can
95    /// reach the URL can read them. Put it behind authentication middleware
96    /// before you call this.
97    pub fn even_in_production(mut self) -> Self {
98        self.force = true;
99        self
100    }
101
102    /// Whether Telescope may mount in this environment.
103    ///
104    /// Outside production it is on unless configuration says otherwise; in
105    /// production it is off unless someone explicitly said yes. The asymmetry
106    /// is the point — the failure mode of a forgotten debug tool is a data
107    /// leak, so the default has to be the safe one.
108    fn allowed(&self, config: &Config) -> bool {
109        self.force || config.bool("telescope.enabled", !config.is_production())
110    }
111
112    /// Mount onto a router directly.
113    ///
114    /// Returns the [`Store`] when Telescope mounted, and `None` when the
115    /// production guard refused. Applications go through [`Plugin`]; this is
116    /// for tests and for an application that builds its own router.
117    pub fn install(self, router: &mut Router, config: &Config) -> Option<Store> {
118        if !self.allowed(config) {
119            rustlavel_core::warn!(
120                "telescope is not mounted: it exposes queries and log messages, and this is a \
121                 production environment. Set `telescope.enabled` to true (and put the route \
122                 behind authentication) if that is what you want."
123            );
124            return None;
125        }
126
127        let mount = normalise_mount(
128            self.mount.clone().unwrap_or_else(|| config.string("telescope.route", DEFAULT_MOUNT)),
129        );
130        let capacity = self
131            .capacity
132            .unwrap_or_else(|| config.int("telescope.limit", DEFAULT_CAPACITY as i64).max(1) as usize);
133        let slow_ms =
134            self.slow_ms.unwrap_or_else(|| config.int("telescope.slow_ms", DEFAULT_SLOW_MS as i64) as f64);
135        let file = self.file.clone().or_else(|| {
136            let configured = config.string("telescope.path", "");
137            (!configured.is_empty()).then(|| PathBuf::from(configured))
138        });
139
140        let store = Store::with_capacity(capacity);
141
142        // Recover the previous run before anything new is recorded, so restored
143        // ids stay below the ones this process will hand out.
144        let journal = file.map(|path| {
145            store.restore(Journal::load(&path, capacity));
146            Journal::open(path)
147        });
148
149        routes::register(
150            router,
151            store.clone(),
152            PageOptions {
153                mount: mount.clone(),
154                app: config.string("app.name", "Rustlavel"),
155                slow_ms,
156                capacity,
157            },
158        );
159
160        // Announced before subscribing, so Telescope's own boot message is not
161        // the first thing in its buffer.
162        rustlavel_core::info!("telescope is recording — dashboard at {mount}");
163
164        events::subscribe(
165            Recorder::new(store.clone())
166                .with_journal(journal)
167                .ignoring(self.ignored.clone())
168                .skipping_path(mount),
169        );
170
171        Some(store)
172    }
173}
174
175impl Plugin for Telescope {
176    fn name(&self) -> &'static str {
177        "telescope"
178    }
179
180    fn register(self: Box<Self>, setup: &mut Setup<'_>) {
181        if let Some(store) = (*self).install(setup.router, setup.config) {
182            // Registered as state so an application handler can read its own
183            // recorded history — a health page, or a test asserting on queries.
184            setup.state(store);
185        }
186    }
187}
188
189/// A mount point is a path prefix: leading slash, no trailing one.
190fn normalise_mount(mount: String) -> String {
191    let trimmed = mount.trim().trim_matches('/');
192    if trimmed.is_empty() { DEFAULT_MOUNT.to_string() } else { format!("/{trimmed}") }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use rustlavel_core::Event;
199    use rustlavel_http::TestClient;
200
201    fn config_for(environment: &str) -> Config {
202        let config = Config::new();
203        config.set("app.env", environment);
204        config.set("app.name", "Test App");
205        config
206    }
207
208    fn mounted(telescope: Telescope, config: &Config) -> (Option<Store>, Router) {
209        let mut router = Router::new();
210        let store = telescope.install(&mut router, config);
211        (store, router)
212    }
213
214    fn patterns(router: &Router) -> Vec<String> {
215        router.routes().iter().map(|route| route.pattern.clone()).collect()
216    }
217
218    #[test]
219    fn the_plugin_registers_the_dashboard_and_its_api() {
220        let _guard = crate::test_support::exclusive();
221        events::clear_subscribers();
222
223        let (store, router) = mounted(Telescope::new(), &config_for("local"));
224
225        assert!(store.is_some());
226        let patterns = patterns(&router);
227        assert!(patterns.contains(&"/telescope".to_string()));
228        assert!(patterns.contains(&"/telescope/api/entries".to_string()));
229        assert!(patterns.contains(&"/telescope/api/entries/{id}".to_string()));
230        // The listing pattern carries both a GET and a DELETE route.
231        assert_eq!(patterns.iter().filter(|p| *p == "/telescope/api/entries").count(), 2);
232
233        events::clear_subscribers();
234    }
235
236    #[test]
237    fn production_refuses_to_mount() {
238        let _guard = crate::test_support::exclusive();
239        events::clear_subscribers();
240
241        let (store, router) = mounted(Telescope::new(), &config_for("production"));
242
243        assert!(store.is_none());
244        assert!(patterns(&router).is_empty());
245        // And nothing is listening on the bus, so nothing is recorded either.
246        assert!(!events::has_subscribers());
247
248        events::clear_subscribers();
249    }
250
251    #[test]
252    fn production_mounts_when_configuration_explicitly_enables_it() {
253        let _guard = crate::test_support::exclusive();
254        events::clear_subscribers();
255
256        let config = config_for("production");
257        config.set("telescope.enabled", true);
258        let (store, router) = mounted(Telescope::new(), &config);
259
260        assert!(store.is_some());
261        assert!(patterns(&router).contains(&"/telescope".to_string()));
262
263        events::clear_subscribers();
264    }
265
266    #[test]
267    fn production_mounts_when_the_application_forces_it_in_code() {
268        let _guard = crate::test_support::exclusive();
269        events::clear_subscribers();
270
271        let (store, _) = mounted(Telescope::new().even_in_production(), &config_for("production"));
272        assert!(store.is_some());
273
274        events::clear_subscribers();
275    }
276
277    #[test]
278    fn configuration_can_switch_it_off_outside_production_too() {
279        let _guard = crate::test_support::exclusive();
280        events::clear_subscribers();
281
282        let config = config_for("local");
283        config.set("telescope.enabled", false);
284        let (store, _) = mounted(Telescope::new(), &config);
285
286        assert!(store.is_none());
287
288        events::clear_subscribers();
289    }
290
291    #[test]
292    fn a_custom_mount_point_moves_every_route() {
293        let _guard = crate::test_support::exclusive();
294        events::clear_subscribers();
295
296        let (_, router) = mounted(Telescope::new().at("_debug/"), &config_for("local"));
297
298        let patterns = patterns(&router);
299        assert!(patterns.contains(&"/_debug".to_string()));
300        assert!(patterns.contains(&"/_debug/api/entries".to_string()));
301
302        events::clear_subscribers();
303    }
304
305    #[test]
306    fn configuration_supplies_the_mount_point_capacity_and_threshold() {
307        let _guard = crate::test_support::exclusive();
308        events::clear_subscribers();
309
310        let config = config_for("local");
311        config.set("telescope.route", "/insight");
312        config.set("telescope.limit", 7);
313        config.set("telescope.slow_ms", 25);
314        let (store, router) = mounted(Telescope::new(), &config);
315
316        assert_eq!(store.expect("mounted").capacity(), 7);
317        assert!(patterns(&router).contains(&"/insight".to_string()));
318
319        events::clear_subscribers();
320    }
321
322    #[test]
323    fn a_builder_call_wins_over_configuration() {
324        let _guard = crate::test_support::exclusive();
325        events::clear_subscribers();
326
327        let config = config_for("local");
328        config.set("telescope.limit", 7);
329        let (store, _) = mounted(Telescope::new().capacity(11), &config);
330
331        assert_eq!(store.expect("mounted").capacity(), 11);
332
333        events::clear_subscribers();
334    }
335
336    #[tokio::test]
337    async fn a_mounted_telescope_records_and_serves_what_it_recorded() {
338        // Held for the whole test, not just the setup: the requests below go
339        // through the router, which dispatches an `http.request` event for each
340        // one. Releasing early would leak those onto the bus for whichever test
341        // is subscribed next.
342        let _guard = crate::test_support::exclusive_async().await;
343        events::clear_subscribers();
344
345        let mut router = Router::new();
346        Telescope::new().install(&mut router, &config_for("local")).expect("mounted");
347        Event::new("http.request")
348            .with("method", "GET")
349            .with("path", "/orders")
350            .with("status", 200)
351            .dispatch();
352
353        events::clear_subscribers();
354
355        let client = TestClient::new(router);
356        client.get("/telescope").await.assert_ok().assert_see("GET /orders");
357        client
358            .get("/telescope/api/entries")
359            .await
360            .assert_ok()
361            .assert_json("entries.0.summary", "GET /orders");
362    }
363
364    #[test]
365    fn the_dashboard_does_not_record_its_own_requests() {
366        let _guard = crate::test_support::exclusive();
367        events::clear_subscribers();
368
369        let mut router = Router::new();
370        let store = Telescope::new().install(&mut router, &config_for("local")).expect("mounted");
371
372        // What the server dispatches after serving the dashboard itself.
373        Event::new("http.request").with("path", "/telescope/api/entries").dispatch();
374
375        assert!(store.is_empty());
376
377        events::clear_subscribers();
378    }
379
380    #[test]
381    fn a_persisted_run_is_loaded_back_on_boot() {
382        let _guard = crate::test_support::exclusive();
383        events::clear_subscribers();
384
385        let path = std::env::temp_dir()
386            .join(format!("rustlavel-telescope-tests-{}", std::process::id()))
387            .join("plugin-persistence.jsonl");
388        std::fs::create_dir_all(path.parent().expect("parent")).expect("temp dir");
389        let _ = std::fs::remove_file(&path);
390
391        let mut first = Router::new();
392        let store =
393            Telescope::new().persist_to(&path).install(&mut first, &config_for("local")).expect("mounted");
394        Event::new("log").with("message", "from the previous run").dispatch();
395        Event::new("http.request")
396            .with("method", "GET")
397            .with("path", "/orders")
398            .took(std::time::Duration::from_secs(5))
399            .dispatch();
400        assert_eq!(store.len(), 2);
401        events::clear_subscribers();
402
403        // Wait for the writer thread: three lines, because the log entry is
404        // written once on its own and again once the request has claimed it.
405        for _ in 0..200 {
406            if std::fs::read_to_string(&path).is_ok_and(|c| c.lines().count() >= 3) {
407                break;
408            }
409            std::thread::sleep(std::time::Duration::from_millis(10));
410        }
411
412        let mut second = Router::new();
413        let restored =
414            Telescope::new().persist_to(&path).install(&mut second, &config_for("local")).expect("mounted");
415
416        assert_eq!(restored.len(), 2);
417        let entries = restored.entries(&Default::default());
418        assert_eq!(entries[0].summary(), "GET /orders");
419        assert_eq!(entries[1].summary(), "from the previous run");
420        // And the log line is still attached to the request that caused it.
421        assert_eq!(restored.related(entries[0].id).len(), 1);
422
423        events::clear_subscribers();
424        let _ = std::fs::remove_file(&path);
425    }
426
427    #[test]
428    fn a_mount_point_is_normalised_into_a_path_prefix() {
429        assert_eq!(normalise_mount("telescope".into()), "/telescope");
430        assert_eq!(normalise_mount("/debug/".into()), "/debug");
431        assert_eq!(normalise_mount("  ".into()), DEFAULT_MOUNT);
432    }
433}