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 can't be empty");
43        debug_assert!(
44            !path.starts_with('/'),
45            "diagnostic path can't be start with `/`"
46        );
47        debug_assert!(
48            !path.ends_with('/'),
49            "diagnostic path can't be end with `/`"
50        );
51        debug_assert!(
52            !path.contains("//"),
53            "diagnostic path can't 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    pub time: Instant,
117    pub value: f64,
118}
119
120/// A timeline of [`DiagnosticMeasurement`]s of a specific type.
121/// Diagnostic examples: frames per second, CPU usage, network latency
122#[derive(Debug)]
123pub struct Diagnostic {
124    path: DiagnosticPath,
125    pub suffix: Cow<'static, str>,
126    history: VecDeque<DiagnosticMeasurement>,
127    sum: f64,
128    ema: f64,
129    ema_smoothing_factor: f64,
130    max_history_length: usize,
131    pub is_enabled: bool,
132}
133
134impl Diagnostic {
135    /// Add a new value as a [`DiagnosticMeasurement`].
136    pub fn add_measurement(&mut self, measurement: DiagnosticMeasurement) {
137        if measurement.value.is_nan() {
138            // Skip calculating the moving average.
139        } else if let Some(previous) = self.measurement() {
140            let delta = (measurement.time - previous.time).as_secs_f64();
141            let alpha = (delta / self.ema_smoothing_factor).clamp(0.0, 1.0);
142            self.ema += alpha * (measurement.value - self.ema);
143        } else {
144            self.ema = measurement.value;
145        }
146
147        if self.max_history_length > 1 {
148            if self.history.len() >= self.max_history_length {
149                if let Some(removed_diagnostic) = self.history.pop_front() {
150                    if !removed_diagnostic.value.is_nan() {
151                        self.sum -= removed_diagnostic.value;
152                    }
153                }
154            }
155
156            if measurement.value.is_finite() {
157                self.sum += measurement.value;
158            }
159        } else {
160            self.history.clear();
161            if measurement.value.is_nan() {
162                self.sum = 0.0;
163            } else {
164                self.sum = measurement.value;
165            }
166        }
167
168        self.history.push_back(measurement);
169    }
170
171    /// Create a new diagnostic with the given path.
172    pub fn new(path: DiagnosticPath) -> Diagnostic {
173        Diagnostic {
174            path,
175            suffix: Cow::Borrowed(""),
176            history: VecDeque::with_capacity(DEFAULT_MAX_HISTORY_LENGTH),
177            max_history_length: DEFAULT_MAX_HISTORY_LENGTH,
178            sum: 0.0,
179            ema: 0.0,
180            ema_smoothing_factor: 2.0 / 21.0,
181            is_enabled: true,
182        }
183    }
184
185    /// Set the maximum history length.
186    #[must_use]
187    pub fn with_max_history_length(mut self, max_history_length: usize) -> Self {
188        self.max_history_length = max_history_length;
189
190        // reserve/reserve_exact reserve space for n *additional* elements.
191        let expected_capacity = self
192            .max_history_length
193            .saturating_sub(self.history.capacity());
194        self.history.reserve_exact(expected_capacity);
195        self.history.shrink_to(expected_capacity);
196        self
197    }
198
199    /// Add a suffix to use when logging the value, can be used to show a unit.
200    #[must_use]
201    pub fn with_suffix(mut self, suffix: impl Into<Cow<'static, str>>) -> Self {
202        self.suffix = suffix.into();
203        self
204    }
205
206    /// The smoothing factor used for the exponential smoothing used for
207    /// [`smoothed`](Self::smoothed).
208    ///
209    /// If measurements come in less frequently than `smoothing_factor` seconds
210    /// apart, no smoothing will be applied. As measurements come in more
211    /// frequently, the smoothing takes a greater effect such that it takes
212    /// approximately `smoothing_factor` seconds for 83% of an instantaneous
213    /// change in measurement to e reflected in the smoothed value.
214    ///
215    /// A smoothing factor of 0.0 will effectively disable smoothing.
216    #[must_use]
217    pub fn with_smoothing_factor(mut self, smoothing_factor: f64) -> Self {
218        self.ema_smoothing_factor = smoothing_factor;
219        self
220    }
221
222    pub fn path(&self) -> &DiagnosticPath {
223        &self.path
224    }
225
226    /// Get the latest measurement from this diagnostic.
227    #[inline]
228    pub fn measurement(&self) -> Option<&DiagnosticMeasurement> {
229        self.history.back()
230    }
231
232    /// Get the latest value from this diagnostic.
233    pub fn value(&self) -> Option<f64> {
234        self.measurement().map(|measurement| measurement.value)
235    }
236
237    /// Return the simple moving average of this diagnostic's recent values.
238    /// N.B. this a cheap operation as the sum is cached.
239    pub fn average(&self) -> Option<f64> {
240        if !self.history.is_empty() {
241            Some(self.sum / self.history.len() as f64)
242        } else {
243            None
244        }
245    }
246
247    /// Return the exponential moving average of this diagnostic.
248    ///
249    /// This is by default tuned to behave reasonably well for a typical
250    /// measurement that changes every frame such as frametime. This can be
251    /// adjusted using [`with_smoothing_factor`](Self::with_smoothing_factor).
252    pub fn smoothed(&self) -> Option<f64> {
253        if !self.history.is_empty() {
254            Some(self.ema)
255        } else {
256            None
257        }
258    }
259
260    /// Return the number of elements for this diagnostic.
261    pub fn history_len(&self) -> usize {
262        self.history.len()
263    }
264
265    /// Return the duration between the oldest and most recent values for this diagnostic.
266    pub fn duration(&self) -> Option<Duration> {
267        if self.history.len() < 2 {
268            return None;
269        }
270
271        if let Some(newest) = self.history.back() {
272            if let Some(oldest) = self.history.front() {
273                return Some(newest.time.duration_since(oldest.time));
274            }
275        }
276
277        None
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    pub fn values(&self) -> impl Iterator<Item = &f64> {
286        self.history.iter().map(|x| &x.value)
287    }
288
289    pub fn measurements(&self) -> impl Iterator<Item = &DiagnosticMeasurement> {
290        self.history.iter()
291    }
292
293    /// Clear the history of this diagnostic.
294    pub fn clear_history(&mut self) {
295        self.history.clear();
296    }
297}
298
299/// A collection of [`Diagnostic`]s.
300#[derive(Debug, Default, Resource)]
301pub struct DiagnosticsStore {
302    diagnostics: HashMap<DiagnosticPath, Diagnostic, PassHash>,
303}
304
305impl DiagnosticsStore {
306    /// Add a new [`Diagnostic`].
307    ///
308    /// If possible, prefer calling [`App::register_diagnostic`].
309    pub fn add(&mut self, diagnostic: Diagnostic) {
310        self.diagnostics.insert(diagnostic.path.clone(), diagnostic);
311    }
312
313    pub fn get(&self, path: &DiagnosticPath) -> Option<&Diagnostic> {
314        self.diagnostics.get(path)
315    }
316
317    pub fn get_mut(&mut self, path: &DiagnosticPath) -> Option<&mut Diagnostic> {
318        self.diagnostics.get_mut(path)
319    }
320
321    /// Get the latest [`DiagnosticMeasurement`] from an enabled [`Diagnostic`].
322    pub fn get_measurement(&self, path: &DiagnosticPath) -> Option<&DiagnosticMeasurement> {
323        self.diagnostics
324            .get(path)
325            .filter(|diagnostic| diagnostic.is_enabled)
326            .and_then(|diagnostic| diagnostic.measurement())
327    }
328
329    /// Return an iterator over all [`Diagnostic`]s.
330    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
331        self.diagnostics.values()
332    }
333
334    /// Return an iterator over all [`Diagnostic`]s, by mutable reference.
335    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Diagnostic> {
336        self.diagnostics.values_mut()
337    }
338}
339
340/// Record new [`DiagnosticMeasurement`]'s.
341#[derive(SystemParam)]
342pub struct Diagnostics<'w, 's> {
343    store: Res<'w, DiagnosticsStore>,
344    queue: Deferred<'s, DiagnosticsBuffer>,
345}
346
347impl<'w, 's> Diagnostics<'w, 's> {
348    /// Add a measurement to an enabled [`Diagnostic`]. The measurement is passed as a function so that
349    /// it will be evaluated only if the [`Diagnostic`] is enabled. This can be useful if the value is
350    /// costly to calculate.
351    pub fn add_measurement<F>(&mut self, path: &DiagnosticPath, value: F)
352    where
353        F: FnOnce() -> f64,
354    {
355        if self
356            .store
357            .get(path)
358            .is_some_and(|diagnostic| diagnostic.is_enabled)
359        {
360            let measurement = DiagnosticMeasurement {
361                time: Instant::now(),
362                value: value(),
363            };
364            self.queue.0.insert(path.clone(), measurement);
365        }
366    }
367}
368
369#[derive(Default)]
370struct DiagnosticsBuffer(HashMap<DiagnosticPath, DiagnosticMeasurement, PassHash>);
371
372impl SystemBuffer for DiagnosticsBuffer {
373    fn apply(
374        &mut self,
375        _system_meta: &bevy_ecs::system::SystemMeta,
376        world: &mut bevy_ecs::world::World,
377    ) {
378        let mut diagnostics = world.resource_mut::<DiagnosticsStore>();
379        for (path, measurement) in self.0.drain() {
380            if let Some(diagnostic) = diagnostics.get_mut(&path) {
381                diagnostic.add_measurement(measurement);
382            }
383        }
384    }
385}
386
387/// Extend [`App`] with new `register_diagnostic` function.
388pub trait RegisterDiagnostic {
389    /// Register a new [`Diagnostic`] with an [`App`].
390    ///
391    /// Will initialize a [`DiagnosticsStore`] if it doesn't exist.
392    ///
393    /// ```
394    /// use bevy_app::App;
395    /// use bevy_diagnostic::{Diagnostic, DiagnosticsPlugin, DiagnosticPath, RegisterDiagnostic};
396    ///
397    /// const UNIQUE_DIAG_PATH: DiagnosticPath = DiagnosticPath::const_new("foo/bar");
398    ///
399    /// App::new()
400    ///     .register_diagnostic(Diagnostic::new(UNIQUE_DIAG_PATH))
401    ///     .add_plugins(DiagnosticsPlugin)
402    ///     .run();
403    /// ```
404    fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self;
405}
406
407impl RegisterDiagnostic for SubApp {
408    fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self {
409        self.init_resource::<DiagnosticsStore>();
410        let mut diagnostics = self.world_mut().resource_mut::<DiagnosticsStore>();
411        diagnostics.add(diagnostic);
412
413        self
414    }
415}
416
417impl RegisterDiagnostic for App {
418    fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self {
419        SubApp::register_diagnostic(self.main_mut(), diagnostic);
420        self
421    }
422}