1use 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
26pub const DEFAULT_MOUNT: &str = "/telescope";
28pub const DEFAULT_SLOW_MS: f64 = 100.0;
30
31#[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 pub fn at(mut self, mount: impl Into<String>) -> Self {
62 self.mount = Some(mount.into());
63 self
64 }
65
66 pub fn capacity(mut self, entries: usize) -> Self {
68 self.capacity = Some(entries);
69 self
70 }
71
72 pub fn slow_after_ms(mut self, millis: f64) -> Self {
74 self.slow_ms = Some(millis);
75 self
76 }
77
78 pub fn persist_to(mut self, path: impl Into<PathBuf>) -> Self {
81 self.file = Some(path.into());
82 self
83 }
84
85 pub fn ignore(mut self, kind: impl Into<String>) -> Self {
87 self.ignored.push(kind.into());
88 self
89 }
90
91 pub fn even_in_production(mut self) -> Self {
98 self.force = true;
99 self
100 }
101
102 fn allowed(&self, config: &Config) -> bool {
109 self.force || config.bool("telescope.enabled", !config.is_production())
110 }
111
112 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 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 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 setup.state(store);
185 }
186 }
187}
188
189fn 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 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 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 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 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 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 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}