Skip to main content

guinea_core/observability/
mod.rs

1//! Watching a running application from outside it: the public surface a
2//! tool - devtools, a test, a logger - reads, and what a plugin offers it.
3//!
4//! - what happened, and why: [`trace`](crate::trace), with
5//!   [`mark_anywhere`] for something off the UI thread;
6//! - what came or went: [`changes`];
7//! - what a backend or a plugin knows about itself: [`panels`];
8//! - where a frame went: [`profiling`];
9//! - the application's own `tracing` events: [`layer`].
10
11pub mod changes;
12pub mod panels;
13
14use std::cell::RefCell;
15
16use crate::trace::{self, Point};
17
18pub use crate::trace::is_observed;
19
20/// How long a segment has to draw before it is worth a line in the trace.
21/// `GUINEA_TRACE_RENDER_MS` moves it; `0` records every frame.
22fn render_threshold() -> u64 {
23    static MICROSECONDS: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
24    *MICROSECONDS.get_or_init(|| {
25        std::env::var("GUINEA_TRACE_RENDER_MS")
26            .ok()
27            .and_then(|given| given.parse::<f64>().ok())
28            .map_or(2_000, |milliseconds| (milliseconds * 1000.0) as u64)
29    })
30}
31
32thread_local! {
33    static EVERY_RENDER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
34}
35
36/// Records every render on this thread, however quick, while the returned
37/// guard lives.
38///
39/// For a test, which asks which segments a cause redrew. The threshold is
40/// noise control for devtools watching a live application, and a test's draw
41/// is usually under it. Per thread rather than per process: tests run side by
42/// side, and a setting one of them changed would be every other's too.
43pub fn record_every_render() -> EveryRender {
44    EveryRender(EVERY_RENDER.replace(true))
45}
46
47/// Puts the threshold back when dropped. See [`record_every_render`].
48pub struct EveryRender(bool);
49
50impl Drop for EveryRender {
51    fn drop(&mut self) {
52        let _gone = EVERY_RENDER.try_with(|every| every.set(self.0));
53    }
54}
55
56/// Times one segment's drawing, and records it if it took long enough to be
57/// worth seeing. Sixty frames a second of every page is noise, not a trace.
58///
59/// The backends put one of these around the call into a page's or layout's
60/// own `render`, which is where the application's drawing code runs.
61pub struct Rendering {
62    segment: &'static str,
63    started: std::time::Instant,
64    /// Held for as long as the drawing lasts; the profiler reads it on drop.
65    #[cfg(feature = "profiling")]
66    _zone: Option<puffin::ProfilerScope>,
67}
68
69impl Rendering {
70    pub fn of(segment: &'static str) -> Option<Rendering> {
71        #[cfg(feature = "profiling")]
72        {
73            let zone = profiling::zone(segment);
74            if !trace::is_observed_anywhere() && zone.is_none() {
75                return None;
76            }
77
78            return Some(Rendering {
79                segment,
80                started: std::time::Instant::now(),
81                _zone: zone,
82            });
83        }
84
85        #[cfg(not(feature = "profiling"))]
86        trace::is_observed_anywhere().then(|| Rendering {
87            segment,
88            started: std::time::Instant::now(),
89        })
90    }
91}
92
93/// The puffin profiler, when it was compiled in and switched on.
94///
95/// A profiler is a second reader of the same moments the trace marks: the
96/// trace says what happened and why, the profiler says where the frame went.
97pub mod profiling {
98    /// Whether zones are being recorded. Always `false` without the
99    /// `profiling` feature.
100    pub fn on() -> bool {
101        #[cfg(feature = "profiling")]
102        {
103            puffin::are_scopes_on()
104        }
105        #[cfg(not(feature = "profiling"))]
106        {
107            false
108        }
109    }
110
111    /// Starts or stops recording. Does nothing without the feature.
112    pub fn record(on: bool) {
113        #[cfg(feature = "profiling")]
114        puffin::set_scopes_on(on);
115        #[cfg(not(feature = "profiling"))]
116        let _ = on;
117    }
118
119    /// Ends the frame the profiler is collecting. A backend calls this once
120    /// per frame it draws.
121    pub fn frame_done() {
122        #[cfg(feature = "profiling")]
123        puffin::GlobalProfiler::lock().new_frame();
124    }
125
126    /// A `render` zone for as long as the returned value lives, the segment
127    /// as its data - which is how puffin carries a name that changes.
128    #[cfg(feature = "profiling")]
129    pub(super) fn zone(segment: &'static str) -> Option<puffin::ProfilerScope> {
130        if !puffin::are_scopes_on() {
131            return None;
132        }
133
134        static SCOPE: std::sync::OnceLock<puffin::ScopeId> = std::sync::OnceLock::new();
135        let scope = *SCOPE.get_or_init(|| {
136            puffin::ThreadProfiler::call(|profiler| {
137                profiler.register_named_scope("render", "guinea", file!(), line!())
138            })
139        });
140
141        Some(puffin::ProfilerScope::new(scope, segment))
142    }
143
144}
145
146impl Drop for Rendering {
147    fn drop(&mut self) {
148        let took_us = self.started.elapsed().as_micros() as u64;
149        if took_us < render_threshold() && !EVERY_RENDER.get() {
150            return;
151        }
152
153        let segment = self.segment;
154        trace::mark(|| Point::Render { segment, took_us });
155    }
156}
157
158/// Marks `point` under what is running now, from any thread.
159///
160/// On the thread devtools watch, it is marked at once. Anywhere else it is
161/// marked there when the UI thread next runs, under the cause that was current
162/// here, so a write from background work still leads back to what started it.
163pub fn mark_anywhere(point: impl FnOnce() -> Point + Send + 'static) {
164    if trace::is_observed() || !trace::is_observed_anywhere() {
165        trace::mark(point);
166        return;
167    }
168    let cause = trace::current();
169    let marked = crate::actor::try_invoke_on_ui(move || {
170        trace::mark_under(cause, point);
171    });
172    if let Err(unsent) = marked {
173        unsent();
174    }
175}
176
177/// Opens `point` from any thread, with the id [`trace::reserve`] gave, the way
178/// [`mark_anywhere`] marks one.
179pub(crate) fn begin_anywhere(
180    id: trace::Cause,
181    parent: Option<trace::Cause>,
182    point: impl FnOnce() -> Point + Send + 'static,
183) {
184    if trace::is_observed() || !trace::is_observed_anywhere() {
185        trace::begin_as(id, parent, point);
186        return;
187    }
188    let begun = crate::actor::try_invoke_on_ui(move || trace::begin_as(id, parent, point));
189    if let Err(unsent) = begun {
190        unsent();
191    }
192}
193
194/// Closes, from any thread, what [`begin_anywhere`] opened.
195pub(crate) fn end_anywhere(id: trace::Cause, took: std::time::Duration) {
196    if trace::is_observed() || !trace::is_observed_anywhere() {
197        trace::end(id, took);
198        return;
199    }
200    let ended = crate::actor::try_invoke_on_ui(move || trace::end(id, took));
201    if let Err(unsent) = ended {
202        unsent();
203    }
204}
205
206/// A `tracing` layer that puts the application's own events and spans into
207/// the trace, under whatever caused them, while devtools watch or `guinea::`
208/// events are written.
209///
210/// An event is a [`Point::Log`]. A span is a [`Point::Span`], open from when
211/// it is created until it closes and current while it is entered - so what an
212/// `#[instrument]`ed function sends, pushes or logs is traced under it - and
213/// what it took is the time it spent entered, not the time it waited between
214/// polls.
215///
216/// Only the application's own: an event or span written in one of its
217/// workspace's crates. guinea's and every dependency's - winit, wgpu, tokio -
218/// stay out of the trace and off the wire; [`LogLayer::all`] lets them in.
219///
220/// ```no_run
221/// use tracing_subscriber::prelude::*;
222///
223/// tracing_subscriber::registry()
224///     .with(tracing_subscriber::fmt::layer())
225///     .with(guinea_core::observability::layer())
226///     .init();
227/// ```
228pub fn layer() -> LogLayer {
229    LogLayer { all: false }
230}
231
232pub struct LogLayer {
233    all: bool,
234}
235
236impl LogLayer {
237    /// Every event, not only the application's own.
238    pub fn all(self) -> Self {
239        Self { all: true }
240    }
241}
242
243/// Whether an event was written in the application's own code.
244///
245/// Cargo names the files of a workspace member relative to the workspace
246/// root, and those of every other crate - registry, git, a path outside the
247/// workspace - absolutely. So the application is told apart from its
248/// dependencies without a list of either.
249fn written_here(file: Option<&str>) -> bool {
250    file.is_some_and(|file| std::path::Path::new(file).is_relative())
251}
252
253thread_local! {
254    static LOGGING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
255
256    /// The application's spans entered on this thread, innermost last, each
257    /// with the guard that keeps it current and when it was entered.
258    static ENTERED: RefCell<Vec<(tracing::span::Id, trace::Resumed, std::time::Instant)>> =
259        const { RefCell::new(Vec::new()) };
260}
261
262/// A span of the application's own, as the trace knows it: its point, and the
263/// time it has spent entered so far.
264struct Opened {
265    id: trace::Cause,
266    busy: std::time::Duration,
267}
268
269impl LogLayer {
270    fn wants(&self, meta: &tracing::Metadata<'_>) -> bool {
271        !trace::is_point_target(meta.target()) && (self.all || written_here(meta.file()))
272    }
273}
274
275impl<S> tracing_subscriber::Layer<S> for LogLayer
276where
277    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
278{
279    fn on_new_span(
280        &self,
281        attrs: &tracing::span::Attributes<'_>,
282        id: &tracing::span::Id,
283        cx: tracing_subscriber::layer::Context<'_, S>,
284    ) {
285        let meta = attrs.metadata();
286        if !self.wants(meta) || !trace::is_recorded_anywhere() {
287            return;
288        }
289        let Some(span) = cx.span(id) else {
290            return;
291        };
292
293        let parent = span
294            .parent()
295            .and_then(|above| above.extensions().get::<Opened>().map(|opened| opened.id))
296            .or_else(trace::current);
297
298        let mut fields = Text::default();
299        attrs.record(&mut fields);
300
301        let opened = trace::reserve();
302        let (name, level, target) = (meta.name(), *meta.level(), meta.target());
303        let (file, line, module) = (meta.file(), meta.line(), meta.module_path());
304        begin_anywhere(opened, parent, move || Point::Span {
305            name,
306            level,
307            target,
308            file,
309            line,
310            module,
311            fields: fields.finish(),
312        });
313
314        span.extensions_mut().insert(Opened {
315            id: opened,
316            busy: std::time::Duration::ZERO,
317        });
318    }
319
320    fn on_enter(&self, id: &tracing::span::Id, cx: tracing_subscriber::layer::Context<'_, S>) {
321        let Some(opened) = cx
322            .span(id)
323            .and_then(|span| span.extensions().get::<Opened>().map(|opened| opened.id))
324        else {
325            return;
326        };
327
328        let current = trace::resume(Some(opened));
329        ENTERED.with(|entered| {
330            entered
331                .borrow_mut()
332                .push((id.clone(), current, std::time::Instant::now()));
333        });
334    }
335
336    fn on_exit(&self, id: &tracing::span::Id, cx: tracing_subscriber::layer::Context<'_, S>) {
337        let left = ENTERED.with(|entered| {
338            let mut entered = entered.borrow_mut();
339            let at = entered.iter().rposition(|(span, ..)| span == id)?;
340            Some(entered.remove(at))
341        });
342        let Some((_, current, since)) = left else {
343            return;
344        };
345        drop(current);
346
347        if let Some(span) = cx.span(id)
348            && let Some(opened) = span.extensions_mut().get_mut::<Opened>()
349        {
350            opened.busy += since.elapsed();
351        }
352    }
353
354    fn on_close(&self, id: tracing::span::Id, cx: tracing_subscriber::layer::Context<'_, S>) {
355        let Some(opened) = cx
356            .span(&id)
357            .and_then(|span| span.extensions_mut().remove::<Opened>())
358        else {
359            return;
360        };
361
362        end_anywhere(opened.id, opened.busy);
363    }
364
365    fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) {
366        let meta = event.metadata();
367        if !self.wants(meta) || !trace::is_observed_anywhere() || LOGGING.get() {
368            return;
369        }
370
371        LOGGING.set(true);
372        let mut text = Text::default();
373        event.record(&mut text);
374
375        let (level, target) = (*meta.level(), meta.target());
376        let (file, line, module) = (meta.file(), meta.line(), meta.module_path());
377        mark_anywhere(move || Point::Log {
378            level,
379            target,
380            file,
381            line,
382            module,
383            text: text.finish(),
384        });
385        LOGGING.set(false);
386    }
387}
388
389#[derive(Default)]
390struct Text {
391    message: String,
392    fields: Vec<String>,
393}
394
395impl Text {
396    fn finish(self) -> String {
397        let mut text = self.message;
398        for field in self.fields {
399            if !text.is_empty() {
400                text.push(' ');
401            }
402            text.push_str(&field);
403        }
404        text
405    }
406}
407
408impl tracing::field::Visit for Text {
409    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
410        if field.name() == "message" {
411            self.message = value.to_string();
412        } else {
413            self.fields.push(format!("{}={value}", field.name()));
414        }
415    }
416
417    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
418        if field.name() == "message" {
419            self.message = format!("{value:?}");
420        } else {
421            self.fields.push(format!("{}={value:?}", field.name()));
422        }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use std::rc::Rc;
429
430    use super::changes::{Change, changed};
431    use super::panels::{Panel, contribute, contribute_to_app, for_app, for_root};
432    use super::*;
433
434    fn test_panel() -> Option<Panel> {
435        Some(Panel {
436            id: "test",
437            title: "Test",
438            nodes: Vec::new(),
439        })
440    }
441
442    #[test]
443    fn a_panel_is_offered_for_its_root_until_the_guard_goes() {
444        let guard = contribute(7, test_panel);
445        assert_eq!(for_root(7).len(), 1);
446        assert!(for_root(8).is_empty());
447        assert!(for_app().is_empty());
448
449        drop(guard);
450        assert!(for_root(7).is_empty());
451    }
452
453    #[cfg(not(feature = "test-utils"))]
454    struct Queue(std::sync::mpsc::Sender<crate::actor::UiTask>);
455
456    #[cfg(not(feature = "test-utils"))]
457    impl crate::actor::UiDispatcher for Queue {
458        fn init(&self) {}
459
460        fn dispatch(&self, task: crate::actor::UiTask) {
461            let _ = self.0.send(task);
462        }
463    }
464
465    #[test]
466    #[cfg(not(feature = "test-utils"))]
467    fn a_mark_from_another_thread_lands_on_the_watched_one_under_its_cause() {
468        let (tx, rx) = std::sync::mpsc::channel();
469        crate::actor::set_ui_dispatcher(Queue(tx));
470        let seen = Rc::new(RefCell::new(Vec::new()));
471        let sink = seen.clone();
472        trace::observe(move |record| {
473            if let trace::Trace::Mark(record) = record {
474                sink.borrow_mut().push((record.parent, record.point.kind()));
475            }
476        });
477
478        let action = trace::mark(|| Point::Action { message: "Save" });
479        std::thread::spawn(move || {
480            let _resumed = trace::resume(Some(action));
481            mark_anywhere(|| Point::Note("written".into()));
482        })
483        .join()
484        .expect("writer");
485        for task in rx.try_iter() {
486            task();
487        }
488        trace::stop_observing();
489
490        assert_eq!(
491            *seen.borrow(),
492            [(None, "action"), (Some(action), "note")],
493            "the write is marked here, under the action that caused it"
494        );
495    }
496
497    #[test]
498    fn an_ordinary_event_is_traced_under_what_caused_it() {
499        use tracing_subscriber::layer::SubscriberExt;
500
501        let seen = Rc::new(RefCell::new(Vec::new()));
502        let sink = seen.clone();
503        trace::observe(move |record| {
504            if let trace::Trace::Mark(record) = record
505                && let Point::Log { text, .. } = &record.point
506            {
507                sink.borrow_mut().push((record.parent, text.clone()));
508            }
509        });
510
511        let subscriber = tracing_subscriber::registry().with(layer());
512        let action = trace::mark(|| Point::Action { message: "Kill" });
513        tracing::subscriber::with_default(subscriber, || {
514            let _resumed = trace::resume(Some(action));
515            tracing::info!(pid = 42, "process killed");
516            tracing::debug!(target: "guinea::note", "a guinea point, logged: not traced twice");
517        });
518        trace::stop_observing();
519
520        assert_eq!(
521            *seen.borrow(),
522            [(Some(action), "process killed pid=42".to_string())]
523        );
524    }
525
526    #[test]
527    fn a_logged_event_says_where_it_was_written() {
528        use tracing_subscriber::layer::SubscriberExt;
529
530        let seen = Rc::new(RefCell::new(Vec::new()));
531        let sink = seen.clone();
532        trace::observe(move |record| {
533            if let trace::Trace::Mark(record) = record
534                && let Point::Log {
535                    file, line, module, ..
536                } = &record.point
537            {
538                sink.borrow_mut().push((*file, line.is_some(), *module));
539            }
540        });
541
542        let subscriber = tracing_subscriber::registry().with(layer());
543        tracing::subscriber::with_default(subscriber, || tracing::info!("here"));
544        trace::stop_observing();
545
546        assert_eq!(
547            *seen.borrow(),
548            [(Some(file!()), true, Some(module_path!()))]
549        );
550    }
551
552    #[test]
553    fn a_span_is_open_under_what_caused_it_and_current_while_entered() {
554        use tracing_subscriber::layer::SubscriberExt;
555
556        let seen = Rc::new(RefCell::new(Vec::new()));
557        let sink = seen.clone();
558        trace::observe(move |trace| sink.borrow_mut().push(trace.clone()));
559
560        let subscriber = tracing_subscriber::registry().with(layer());
561        let action = trace::mark(|| Point::Action { message: "Refresh" });
562        tracing::subscriber::with_default(subscriber, || {
563            let _resumed = trace::resume(Some(action));
564            let span = tracing::info_span!("rows_from_report", rows = 3);
565            {
566                let _entered = span.enter();
567                trace::mark(|| Point::Push { reducer: "Rows" });
568            }
569            trace::mark(|| Point::Note("after the span".into()));
570        });
571        trace::stop_observing();
572
573        let seen = seen.borrow();
574        let span = seen
575            .iter()
576            .find_map(|trace| match trace {
577                trace::Trace::Begin(record) => Some(record),
578                _ => None,
579            })
580            .expect("the span was opened");
581        assert_eq!(span.parent, Some(action));
582        assert!(
583            matches!(
584                &span.point,
585                Point::Span { name: "rows_from_report", fields, file: Some(file), .. }
586                    if fields == "rows=3" && *file == file!()
587            ),
588            "{:?}",
589            span.point
590        );
591
592        let parent_of = |kind: &str| {
593            seen.iter().find_map(|trace| match trace {
594                trace::Trace::Mark(record) if record.point.kind() == kind => Some(record.parent),
595                _ => None,
596            })
597        };
598        assert_eq!(parent_of("push"), Some(Some(span.id)), "inside it, under it");
599        assert_eq!(parent_of("note"), Some(Some(action)), "after it, under what was there");
600        assert!(
601            matches!(seen.last(), Some(trace::Trace::End { id, .. }) if *id == span.id),
602            "{seen:?}"
603        );
604    }
605
606    #[test]
607    fn nothing_is_built_for_a_change_nobody_watches() {
608        let built = std::cell::Cell::new(false);
609        changed(|| {
610            built.set(true);
611            Change::TimerStarted { id: 0 }
612        });
613
614        assert!(!built.get());
615    }
616
617    #[test]
618    fn a_span_is_at_the_level_it_was_opened_at() {
619        use tracing_subscriber::layer::SubscriberExt;
620
621        let seen = Rc::new(RefCell::new(Vec::new()));
622        let sink = seen.clone();
623        trace::observe(move |trace| {
624            if let trace::Trace::Begin(record) = trace
625                && let Point::Span { name, level, .. } = &record.point
626            {
627                sink.borrow_mut().push((*name, *level));
628            }
629        });
630
631        let subscriber = tracing_subscriber::registry().with(layer());
632        tracing::subscriber::with_default(subscriber, || {
633            let _warned = tracing::warn_span!("retrying").entered();
634            let _debugged = tracing::debug_span!("parsing").entered();
635        });
636        trace::stop_observing();
637
638        assert_eq!(
639            *seen.borrow(),
640            [("retrying", tracing::Level::WARN), ("parsing", tracing::Level::DEBUG)]
641        );
642    }
643
644    #[test]
645    fn a_span_took_the_time_it_was_entered_not_the_time_between() {
646        use tracing_subscriber::layer::SubscriberExt;
647
648        let took = Rc::new(RefCell::new(Vec::new()));
649        let sink = took.clone();
650        trace::observe(move |trace| {
651            if let trace::Trace::End { took, .. } = trace {
652                sink.borrow_mut().push(*took);
653            }
654        });
655
656        let subscriber = tracing_subscriber::registry().with(layer());
657        tracing::subscriber::with_default(subscriber, || {
658            let span = tracing::info_span!("polled");
659            for _ in 0..2 {
660                let _entered = span.enter();
661                std::thread::sleep(std::time::Duration::from_millis(5));
662            }
663            std::thread::sleep(std::time::Duration::from_millis(40));
664        });
665        trace::stop_observing();
666
667        let took = *took.borrow().first().expect("the span closed");
668        assert!(
669            took >= std::time::Duration::from_millis(10) && took < std::time::Duration::from_millis(40),
670            "two entries of five milliseconds, forty idle after: {took:?}"
671        );
672    }
673
674    #[test]
675    fn a_span_written_elsewhere_is_not_traced() {
676        use tracing_subscriber::layer::SubscriberExt;
677
678        let seen = Rc::new(RefCell::new(0));
679        let sink = seen.clone();
680        trace::observe(move |_| *sink.borrow_mut() += 1);
681
682        let subscriber = tracing_subscriber::registry().with(layer());
683        tracing::subscriber::with_default(subscriber, || {
684            let span = tracing::span!(target: "guinea::handle", tracing::Level::INFO, "a guinea point");
685            let _entered = span.enter();
686        });
687        trace::stop_observing();
688
689        assert_eq!(*seen.borrow(), 0);
690    }
691
692    #[test]
693    fn only_a_file_of_the_workspace_is_the_applications() {
694        assert!(written_here(Some(file!())), "{}", file!());
695        assert!(!written_here(Some(concat!(env!("CARGO_MANIFEST_DIR"), "/src/lib.rs"))));
696        assert!(!written_here(None));
697    }
698
699    #[test]
700    fn an_application_panel_belongs_to_no_root() {
701        let guard = contribute_to_app(test_panel);
702        assert_eq!(for_app().len(), 1);
703        assert!(for_root(0).is_empty());
704
705        drop(guard);
706        assert!(for_app().is_empty());
707    }
708}