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#[derive(Debug, Clone)]
22pub struct DiagnosticPath {
23 path: Cow<'static, str>,
24 hash: u64,
25}
26
27impl DiagnosticPath {
28 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 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 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 pub fn as_str(&self) -> &str {
78 &self.path
79 }
80
81 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#[derive(Debug)]
115pub struct DiagnosticMeasurement {
116 pub time: Instant,
118 pub value: f64,
120}
121
122#[derive(Debug)]
125pub struct Diagnostic {
126 path: DiagnosticPath,
127 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 pub is_enabled: bool,
136}
137
138impl Diagnostic {
139 pub fn add_measurement(&mut self, measurement: DiagnosticMeasurement) {
141 if measurement.value.is_nan() {
142 } 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 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 #[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 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 #[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 #[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 pub fn path(&self) -> &DiagnosticPath {
227 &self.path
228 }
229
230 #[inline]
232 pub fn measurement(&self) -> Option<&DiagnosticMeasurement> {
233 self.history.back()
234 }
235
236 pub fn value(&self) -> Option<f64> {
238 self.measurement().map(|measurement| measurement.value)
239 }
240
241 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 pub fn smoothed(&self) -> Option<f64> {
257 if !self.history.is_empty() {
258 Some(self.ema)
259 } else {
260 None
261 }
262 }
263
264 pub fn history_len(&self) -> usize {
266 self.history.len()
267 }
268
269 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 pub fn get_max_history_length(&self) -> usize {
282 self.max_history_length
283 }
284
285 pub fn values(&self) -> impl Iterator<Item = &f64> {
287 self.history.iter().map(|x| &x.value)
288 }
289
290 pub fn measurements(&self) -> impl Iterator<Item = &DiagnosticMeasurement> {
292 self.history.iter()
293 }
294
295 pub fn clear_history(&mut self) {
297 self.history.clear();
298 self.sum = 0.0;
299 self.ema = 0.0;
300 }
301}
302
303#[derive(Debug, Default, Resource)]
305pub struct DiagnosticsStore {
306 diagnostics: HashMap<DiagnosticPath, Diagnostic, PassHash>,
307}
308
309impl DiagnosticsStore {
310 pub fn add(&mut self, diagnostic: Diagnostic) {
314 self.diagnostics.insert(diagnostic.path.clone(), diagnostic);
315 }
316
317 pub fn get(&self, path: &DiagnosticPath) -> Option<&Diagnostic> {
319 self.diagnostics.get(path)
320 }
321
322 pub fn get_mut(&mut self, path: &DiagnosticPath) -> Option<&mut Diagnostic> {
324 self.diagnostics.get_mut(path)
325 }
326
327 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 pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
337 self.diagnostics.values()
338 }
339
340 pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Diagnostic> {
342 self.diagnostics.values_mut()
343 }
344}
345
346#[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 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
393pub trait RegisterDiagnostic {
395 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 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}