bevy_diagnostic/
diagnostic.rs

1use alloc::{borrow::Cow, collections::VecDeque};
2use core::hash::{Hash, Hasher};
3
4use bevy_app::{App, SubApp};
5use bevy_ecs::system::{Deferred, Res, Resource, SystemBuffer, SystemParam};
6use bevy_utils::{hashbrown::HashMap, Duration, Instant, PassHash};
7use const_fnv1a_hash::fnv1a_hash_str_64;
8
9use crate::DEFAULT_MAX_HISTORY_LENGTH;
10
11/// Unique diagnostic path, separated by `/`.
12///
13/// Requirements:
14/// - Can't be empty
15/// - Can't have leading or trailing `/`
16/// - Can't have empty components.
17#[derive(Debug, Clone)]
18pub struct DiagnosticPath {
19    path: Cow<'static, str>,
20    hash: u64,
21}
22
23impl DiagnosticPath {
24    /// Create a new `DiagnosticPath`. Usable in const contexts.
25    ///
26    /// **Note**: path is not validated, so make sure it follows all the requirements.
27    pub const fn const_new(path: &'static str) -> DiagnosticPath {
28        DiagnosticPath {
29            path: Cow::Borrowed(path),
30            hash: fnv1a_hash_str_64(path),
31        }
32    }
33
34    /// Create a new `DiagnosticPath` from the specified string.
35    pub fn new(path: impl Into<Cow<'static, str>>) -> DiagnosticPath {
36        let path = path.into();
37
38        debug_assert!(!path.is_empty(), "diagnostic path can't be empty");
39        debug_assert!(
40            !path.starts_with('/'),
41            "diagnostic path can't be start with `/`"
42        );
43        debug_assert!(
44            !path.ends_with('/'),
45            "diagnostic path can't be end with `/`"
46        );
47        debug_assert!(
48            !path.contains("//"),
49            "diagnostic path can't contain empty components"
50        );
51
52        DiagnosticPath {
53            hash: fnv1a_hash_str_64(&path),
54            path,
55        }
56    }
57
58    /// Create a new `DiagnosticPath` from an iterator over components.
59    pub fn from_components<'a>(components: impl IntoIterator<Item = &'a str>) -> DiagnosticPath {
60        let mut buf = String::new();
61
62        for (i, component) in components.into_iter().enumerate() {
63            if i > 0 {
64                buf.push('/');
65            }
66            buf.push_str(component);
67        }
68
69        DiagnosticPath::new(buf)
70    }
71
72    /// Returns full path, joined by `/`
73    pub fn as_str(&self) -> &str {
74        &self.path
75    }
76
77    /// Returns an iterator over path components.
78    pub fn components(&self) -> impl Iterator<Item = &str> + '_ {
79        self.path.split('/')
80    }
81}
82
83impl From<DiagnosticPath> for String {
84    fn from(path: DiagnosticPath) -> Self {
85        path.path.into()
86    }
87}
88
89impl Eq for DiagnosticPath {}
90
91impl PartialEq for DiagnosticPath {
92    fn eq(&self, other: &Self) -> bool {
93        self.hash == other.hash && self.path == other.path
94    }
95}
96
97impl Hash for DiagnosticPath {
98    fn hash<H: Hasher>(&self, state: &mut H) {
99        state.write_u64(self.hash);
100    }
101}
102
103impl core::fmt::Display for DiagnosticPath {
104    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
105        self.path.fmt(f)
106    }
107}
108
109/// A single measurement of a [`Diagnostic`].
110#[derive(Debug)]
111pub struct DiagnosticMeasurement {
112    pub time: Instant,
113    pub value: f64,
114}
115
116/// A timeline of [`DiagnosticMeasurement`]s of a specific type.
117/// Diagnostic examples: frames per second, CPU usage, network latency
118#[derive(Debug)]
119pub struct Diagnostic {
120    path: DiagnosticPath,
121    pub suffix: Cow<'static, str>,
122    history: VecDeque<DiagnosticMeasurement>,
123    sum: f64,
124    ema: f64,
125    ema_smoothing_factor: f64,
126    max_history_length: usize,
127    pub is_enabled: bool,
128}
129
130impl Diagnostic {
131    /// Add a new value as a [`DiagnosticMeasurement`].
132    pub fn add_measurement(&mut self, measurement: DiagnosticMeasurement) {
133        if measurement.value.is_nan() {
134            // Skip calculating the moving average.
135        } else if let Some(previous) = self.measurement() {
136            let delta = (measurement.time - previous.time).as_secs_f64();
137            let alpha = (delta / self.ema_smoothing_factor).clamp(0.0, 1.0);
138            self.ema += alpha * (measurement.value - self.ema);
139        } else {
140            self.ema = measurement.value;
141        }
142
143        if self.max_history_length > 1 {
144            if self.history.len() >= self.max_history_length {
145                if let Some(removed_diagnostic) = self.history.pop_front() {
146                    if !removed_diagnostic.value.is_nan() {
147                        self.sum -= removed_diagnostic.value;
148                    }
149                }
150            }
151
152            if measurement.value.is_finite() {
153                self.sum += measurement.value;
154            }
155        } else {
156            self.history.clear();
157            if measurement.value.is_nan() {
158                self.sum = 0.0;
159            } else {
160                self.sum = measurement.value;
161            }
162        }
163
164        self.history.push_back(measurement);
165    }
166
167    /// Create a new diagnostic with the given path.
168    pub fn new(path: DiagnosticPath) -> Diagnostic {
169        Diagnostic {
170            path,
171            suffix: Cow::Borrowed(""),
172            history: VecDeque::with_capacity(DEFAULT_MAX_HISTORY_LENGTH),
173            max_history_length: DEFAULT_MAX_HISTORY_LENGTH,
174            sum: 0.0,
175            ema: 0.0,
176            ema_smoothing_factor: 2.0 / 21.0,
177            is_enabled: true,
178        }
179    }
180
181    /// Set the maximum history length.
182    #[must_use]
183    pub fn with_max_history_length(mut self, max_history_length: usize) -> Self {
184        self.max_history_length = max_history_length;
185
186        // reserve/reserve_exact reserve space for n *additional* elements.
187        let expected_capacity = self
188            .max_history_length
189            .saturating_sub(self.history.capacity());
190        self.history.reserve_exact(expected_capacity);
191        self.history.shrink_to(expected_capacity);
192        self
193    }
194
195    /// Add a suffix to use when logging the value, can be used to show a unit.
196    #[must_use]
197    pub fn with_suffix(mut self, suffix: impl Into<Cow<'static, str>>) -> Self {
198        self.suffix = suffix.into();
199        self
200    }
201
202    /// The smoothing factor used for the exponential smoothing used for
203    /// [`smoothed`](Self::smoothed).
204    ///
205    /// If measurements come in less frequently than `smoothing_factor` seconds
206    /// apart, no smoothing will be applied. As measurements come in more
207    /// frequently, the smoothing takes a greater effect such that it takes
208    /// approximately `smoothing_factor` seconds for 83% of an instantaneous
209    /// change in measurement to e reflected in the smoothed value.
210    ///
211    /// A smoothing factor of 0.0 will effectively disable smoothing.
212    #[must_use]
213    pub fn with_smoothing_factor(mut self, smoothing_factor: f64) -> Self {
214        self.ema_smoothing_factor = smoothing_factor;
215        self
216    }
217
218    pub fn path(&self) -> &DiagnosticPath {
219        &self.path
220    }
221
222    /// Get the latest measurement from this diagnostic.
223    #[inline]
224    pub fn measurement(&self) -> Option<&DiagnosticMeasurement> {
225        self.history.back()
226    }
227
228    /// Get the latest value from this diagnostic.
229    pub fn value(&self) -> Option<f64> {
230        self.measurement().map(|measurement| measurement.value)
231    }
232
233    /// Return the simple moving average of this diagnostic's recent values.
234    /// N.B. this a cheap operation as the sum is cached.
235    pub fn average(&self) -> Option<f64> {
236        if !self.history.is_empty() {
237            Some(self.sum / self.history.len() as f64)
238        } else {
239            None
240        }
241    }
242
243    /// Return the exponential moving average of this diagnostic.
244    ///
245    /// This is by default tuned to behave reasonably well for a typical
246    /// measurement that changes every frame such as frametime. This can be
247    /// adjusted using [`with_smoothing_factor`](Self::with_smoothing_factor).
248    pub fn smoothed(&self) -> Option<f64> {
249        if !self.history.is_empty() {
250            Some(self.ema)
251        } else {
252            None
253        }
254    }
255
256    /// Return the number of elements for this diagnostic.
257    pub fn history_len(&self) -> usize {
258        self.history.len()
259    }
260
261    /// Return the duration between the oldest and most recent values for this diagnostic.
262    pub fn duration(&self) -> Option<Duration> {
263        if self.history.len() < 2 {
264            return None;
265        }
266
267        if let Some(newest) = self.history.back() {
268            if let Some(oldest) = self.history.front() {
269                return Some(newest.time.duration_since(oldest.time));
270            }
271        }
272
273        None
274    }
275
276    /// Return the maximum number of elements for this diagnostic.
277    pub fn get_max_history_length(&self) -> usize {
278        self.max_history_length
279    }
280
281    pub fn values(&self) -> impl Iterator<Item = &f64> {
282        self.history.iter().map(|x| &x.value)
283    }
284
285    pub fn measurements(&self) -> impl Iterator<Item = &DiagnosticMeasurement> {
286        self.history.iter()
287    }
288
289    /// Clear the history of this diagnostic.
290    pub fn clear_history(&mut self) {
291        self.history.clear();
292    }
293}
294
295/// A collection of [`Diagnostic`]s.
296#[derive(Debug, Default, Resource)]
297pub struct DiagnosticsStore {
298    diagnostics: HashMap<DiagnosticPath, Diagnostic, PassHash>,
299}
300
301impl DiagnosticsStore {
302    /// Add a new [`Diagnostic`].
303    ///
304    /// If possible, prefer calling [`App::register_diagnostic`].
305    pub fn add(&mut self, diagnostic: Diagnostic) {
306        self.diagnostics.insert(diagnostic.path.clone(), diagnostic);
307    }
308
309    pub fn get(&self, path: &DiagnosticPath) -> Option<&Diagnostic> {
310        self.diagnostics.get(path)
311    }
312
313    pub fn get_mut(&mut self, path: &DiagnosticPath) -> Option<&mut Diagnostic> {
314        self.diagnostics.get_mut(path)
315    }
316
317    /// Get the latest [`DiagnosticMeasurement`] from an enabled [`Diagnostic`].
318    pub fn get_measurement(&self, path: &DiagnosticPath) -> Option<&DiagnosticMeasurement> {
319        self.diagnostics
320            .get(path)
321            .filter(|diagnostic| diagnostic.is_enabled)
322            .and_then(|diagnostic| diagnostic.measurement())
323    }
324
325    /// Return an iterator over all [`Diagnostic`]s.
326    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
327        self.diagnostics.values()
328    }
329
330    /// Return an iterator over all [`Diagnostic`]s, by mutable reference.
331    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Diagnostic> {
332        self.diagnostics.values_mut()
333    }
334}
335
336/// Record new [`DiagnosticMeasurement`]'s.
337#[derive(SystemParam)]
338pub struct Diagnostics<'w, 's> {
339    store: Res<'w, DiagnosticsStore>,
340    queue: Deferred<'s, DiagnosticsBuffer>,
341}
342
343impl<'w, 's> Diagnostics<'w, 's> {
344    /// Add a measurement to an enabled [`Diagnostic`]. The measurement is passed as a function so that
345    /// it will be evaluated only if the [`Diagnostic`] is enabled. This can be useful if the value is
346    /// costly to calculate.
347    pub fn add_measurement<F>(&mut self, path: &DiagnosticPath, value: F)
348    where
349        F: FnOnce() -> f64,
350    {
351        if self
352            .store
353            .get(path)
354            .filter(|diagnostic| diagnostic.is_enabled)
355            .is_some()
356        {
357            let measurement = DiagnosticMeasurement {
358                time: Instant::now(),
359                value: value(),
360            };
361            self.queue.0.insert(path.clone(), measurement);
362        }
363    }
364}
365
366#[derive(Default)]
367struct DiagnosticsBuffer(HashMap<DiagnosticPath, DiagnosticMeasurement, PassHash>);
368
369impl SystemBuffer for DiagnosticsBuffer {
370    fn apply(
371        &mut self,
372        _system_meta: &bevy_ecs::system::SystemMeta,
373        world: &mut bevy_ecs::world::World,
374    ) {
375        let mut diagnostics = world.resource_mut::<DiagnosticsStore>();
376        for (path, measurement) in self.0.drain() {
377            if let Some(diagnostic) = diagnostics.get_mut(&path) {
378                diagnostic.add_measurement(measurement);
379            }
380        }
381    }
382}
383
384/// Extend [`App`] with new `register_diagnostic` function.
385pub trait RegisterDiagnostic {
386    /// Register a new [`Diagnostic`] with an [`App`].
387    ///
388    /// Will initialize a [`DiagnosticsStore`] if it doesn't exist.
389    ///
390    /// ```
391    /// use bevy_app::App;
392    /// use bevy_diagnostic::{Diagnostic, DiagnosticsPlugin, DiagnosticPath, RegisterDiagnostic};
393    ///
394    /// const UNIQUE_DIAG_PATH: DiagnosticPath = DiagnosticPath::const_new("foo/bar");
395    ///
396    /// App::new()
397    ///     .register_diagnostic(Diagnostic::new(UNIQUE_DIAG_PATH))
398    ///     .add_plugins(DiagnosticsPlugin)
399    ///     .run();
400    /// ```
401    fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self;
402}
403
404impl RegisterDiagnostic for SubApp {
405    fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self {
406        self.init_resource::<DiagnosticsStore>();
407        let mut diagnostics = self.world_mut().resource_mut::<DiagnosticsStore>();
408        diagnostics.add(diagnostic);
409
410        self
411    }
412}
413
414impl RegisterDiagnostic for App {
415    fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self {
416        SubApp::register_diagnostic(self.main_mut(), diagnostic);
417        self
418    }
419}