bevy_diagnostic/
diagnostic.rs

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