Skip to main content

cu_logmon/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2
3//! Lightweight Copper monitor that periodically dumps compact runtime stats over the
4//! standard `debug!` / `info!` logging macros.
5//!
6//! This monitor is `no_std` friendly and keeps allocations to a minimum while still
7//! reporting a per-second summary of the Copperlist cadence and latencies.
8
9extern crate alloc;
10
11use alloc::format;
12use alloc::string::String;
13use alloc::vec;
14use alloc::vec::Vec;
15use core::fmt::Write as _;
16use cu29::prelude::*;
17#[cfg(all(feature = "std", debug_assertions))]
18use cu29_log_runtime::{
19    format_message_only, register_live_log_listener, unregister_live_log_listener,
20};
21#[cfg(not(feature = "std"))]
22use spin::Mutex;
23#[cfg(not(feature = "std"))]
24type MutexGuard<'a, T> = spin::MutexGuard<'a, T, spin::relax::Spin>;
25#[cfg(all(feature = "std", debug_assertions))]
26use std::collections::HashMap;
27#[cfg(feature = "std")]
28use std::sync::{Mutex, MutexGuard};
29
30const REPORT_INTERVAL_SECS: u64 = 1;
31const MAX_LATENCY_SECS: u64 = 5;
32
33#[cfg(all(feature = "std", debug_assertions))]
34fn format_timestamp(time: CuTime) -> String {
35    // Render CuTime (nanoseconds from an epoch) as HH:mm:ss.xxxx where xxxx is 1e-4 s.
36    let nanos = time.as_nanos();
37    let total_seconds = nanos / 1_000_000_000;
38    let hours = total_seconds / 3600;
39    let minutes = (total_seconds / 60) % 60;
40    let seconds = total_seconds % 60;
41    let fractional_1e4 = (nanos % 1_000_000_000) / 100_000; // 4 fractional digits.
42    format!("{hours:02}:{minutes:02}:{seconds:02}.{fractional_1e4:04}")
43}
44
45struct WindowState {
46    total_copperlists: u64,
47    window_copperlists: u32,
48    last_report_at: Option<CuTime>,
49    last_log_duration: CuDuration,
50    end_to_end: CuDurationStatistics,
51    per_component: Vec<CuDurationStatistics>,
52}
53
54impl WindowState {
55    fn new(component_count: usize, max_sample: CuDuration) -> Self {
56        #[cfg(target_os = "none")]
57        info!("WindowState::new: init end_to_end");
58        let end_to_end = CuDurationStatistics::new(max_sample);
59        #[cfg(target_os = "none")]
60        info!("WindowState::new: init per_component");
61        #[cfg(target_os = "none")]
62        info!(
63            "WindowState::new: stats_size={} per_component_bytes={}",
64            core::mem::size_of::<CuDurationStatistics>(),
65            core::mem::size_of::<CuDurationStatistics>() * component_count
66        );
67        let per_component = vec![CuDurationStatistics::new(max_sample); component_count];
68        #[cfg(target_os = "none")]
69        info!("WindowState::new: init done");
70        Self {
71            total_copperlists: 0,
72            window_copperlists: 0,
73            last_report_at: None,
74            last_log_duration: CuDuration::MIN,
75            end_to_end,
76            per_component,
77        }
78    }
79
80    fn reset_window(&mut self, now: CuTime) {
81        self.window_copperlists = 0;
82        self.last_report_at = Some(now);
83        self.end_to_end.reset();
84        for stat in &mut self.per_component {
85            stat.reset();
86        }
87    }
88}
89
90fn lock_window(window: &Mutex<WindowState>) -> MutexGuard<'_, WindowState> {
91    #[cfg(feature = "std")]
92    {
93        window.lock().unwrap_or_else(|poison| poison.into_inner())
94    }
95
96    #[cfg(not(feature = "std"))]
97    {
98        window.lock()
99    }
100}
101
102fn monitor_max_sample(monitor_cfg: Option<&ComponentConfig>) -> CuResult<CuDuration> {
103    if let Some(cfg) = monitor_cfg {
104        if let Some(us) = cfg.get::<u64>("max_latency_us")? {
105            if us == 0 {
106                return Err(CuError::from("cu_logmon max_latency_us must be > 0"));
107            }
108            return Ok(CuDuration::from_micros(us));
109        }
110        if let Some(ms) = cfg.get::<u64>("max_latency_ms")? {
111            if ms == 0 {
112                return Err(CuError::from("cu_logmon max_latency_ms must be > 0"));
113            }
114            return Ok(CuDuration::from_millis(ms));
115        }
116        if let Some(secs) = cfg.get::<u64>("max_latency_secs")? {
117            if secs == 0 {
118                return Err(CuError::from("cu_logmon max_latency_secs must be > 0"));
119            }
120            return Ok(CuDuration::from_secs(secs));
121        }
122    }
123    Ok(CuDuration::from_secs(MAX_LATENCY_SECS))
124}
125
126struct Snapshot {
127    copperlist_index: u64,
128    rate_whole: u64,
129    rate_tenths: u64,
130    e2e_p50_us: u64,
131    e2e_p90_us: u64,
132    e2e_p99_us: u64,
133    e2e_max_us: u64,
134    top4: String,
135    overhead_us: u64,
136}
137
138pub struct CuLogMon {
139    components: &'static [MonitorComponentMetadata],
140    component_count: usize,
141    window: Mutex<WindowState>,
142}
143
144impl CuLogMon {
145    fn component_name(&self, component_id: ComponentId) -> &'static str {
146        debug_assert!(component_id.index() < self.component_count);
147        self.components[component_id.index()].id()
148    }
149
150    fn compute_snapshot(&self, state: &WindowState, now: CuTime) -> Option<Snapshot> {
151        let last_report = state.last_report_at?;
152
153        let elapsed = now - last_report;
154        let elapsed_ns = elapsed.as_nanos();
155
156        if elapsed_ns < CuDuration::from_secs(REPORT_INTERVAL_SECS).as_nanos() {
157            return None;
158        }
159
160        let rate_x10 = (state.window_copperlists as u64 * 10 * 1_000_000_000u64)
161            .checked_div(elapsed_ns)
162            .unwrap_or(0);
163
164        let top4_max_entries = find_top_components_by_max(&state.per_component, 4);
165        let mut top4 = String::new();
166        if top4_max_entries.is_empty() {
167            top4.push_str("none");
168        } else {
169            for (rank, (component_id, dur)) in top4_max_entries.iter().enumerate() {
170                if rank > 0 {
171                    top4.push_str(", ");
172                }
173                let name = self.component_name(*component_id);
174                let _ = write!(&mut top4, "{} {}us", name, dur.as_micros());
175            }
176        }
177
178        let e2e_p50 = state.end_to_end.percentile(0.5).as_micros();
179        let e2e_p90 = state.end_to_end.percentile(0.9).as_micros();
180        let e2e_p99 = state.end_to_end.percentile(0.99).as_micros();
181        // Max can skew low if a bucket underflows; keep it at least as high as p99.
182        let e2e_max = state.end_to_end.max().as_micros().max(e2e_p99);
183
184        Some(Snapshot {
185            copperlist_index: state.total_copperlists,
186            rate_whole: rate_x10 / 10,
187            rate_tenths: rate_x10 % 10,
188            e2e_p50_us: e2e_p50,
189            e2e_p90_us: e2e_p90,
190            e2e_p99_us: e2e_p99,
191            e2e_max_us: e2e_max,
192            top4,
193            overhead_us: state.last_log_duration.as_micros(),
194        })
195    }
196}
197
198fn component_duration(meta: &CuMsgMetadata) -> Option<CuDuration> {
199    let start = Option::<CuTime>::from(meta.process_time.start)?;
200    let end = Option::<CuTime>::from(meta.process_time.end)?;
201    (end >= start).then_some(end - start)
202}
203
204fn end_to_end_latency(msgs: &[&CuMsgMetadata]) -> Option<CuDuration> {
205    let start = msgs
206        .first()
207        .and_then(|m| Option::<CuTime>::from(m.process_time.start));
208    let end = msgs
209        .last()
210        .and_then(|m| Option::<CuTime>::from(m.process_time.end));
211    match (start, end) {
212        (Some(s), Some(e)) if e >= s => Some(e - s),
213        _ => None,
214    }
215}
216
217fn find_top_components_by_max(
218    per_component: &[CuDurationStatistics],
219    limit: usize,
220) -> Vec<(ComponentId, CuDuration)> {
221    let mut ranked: Vec<(ComponentId, CuDuration)> = per_component
222        .iter()
223        .enumerate()
224        .filter_map(|(idx, stats)| {
225            (!stats.is_empty()).then_some((ComponentId::new(idx), stats.max()))
226        })
227        .collect();
228    ranked.sort_unstable_by(|a, b| {
229        b.1.as_nanos()
230            .cmp(&a.1.as_nanos())
231            .then_with(|| a.0.index().cmp(&b.0.index()))
232    });
233    ranked.truncate(limit);
234    ranked
235}
236
237fn component_state_label(state: &CuComponentState) -> &'static str {
238    match state {
239        CuComponentState::Start => "start",
240        CuComponentState::Preprocess => "pre",
241        CuComponentState::Process => "process",
242        CuComponentState::Postprocess => "post",
243        CuComponentState::Stop => "stop",
244    }
245}
246
247impl CuMonitor for CuLogMon {
248    fn new(metadata: CuMonitoringMetadata, _runtime: CuMonitoringRuntime) -> CuResult<Self> {
249        let components = metadata.components();
250        let component_count = components.len();
251        #[cfg(target_os = "none")]
252        info!("CuLogMon::new: component_count={}", component_count);
253        let max_sample = monitor_max_sample(metadata.monitor_config())?;
254        let window = WindowState::new(component_count, max_sample);
255        #[cfg(target_os = "none")]
256        info!("CuLogMon::new: window ready");
257        Ok(Self {
258            components,
259            component_count,
260            window: Mutex::new(window),
261        })
262    }
263
264    fn start(&mut self, ctx: &CuContext) -> CuResult<()> {
265        let mut window = lock_window(&self.window);
266        window.last_report_at = Some(ctx.recent());
267        info!(
268            ctx,
269            "cu_logmon started ({} components)", self.component_count
270        );
271
272        // Also listen to structured logs and print them with color.
273        #[cfg(all(feature = "std", debug_assertions))]
274        register_live_log_listener(|entry, format_str, param_names| {
275            const PARAM_COLOR: &str = "\x1b[36m"; // cyan
276            const RESET: &str = "\x1b[0m";
277
278            let params: Vec<String> = entry.params.iter().map(|v| v.to_string()).collect();
279            let colored_params: Vec<String> = params
280                .iter()
281                .map(|v| format!("{PARAM_COLOR}{v}{RESET}"))
282                .collect();
283            let colored_named: HashMap<String, String> = param_names
284                .iter()
285                .zip(params.iter())
286                .map(|(k, v)| (k.to_string(), format!("{PARAM_COLOR}{v}{RESET}")))
287                .collect();
288
289            if let Ok(msg) =
290                format_message_only(format_str, colored_params.as_slice(), &colored_named)
291            {
292                let level_color = match entry.level {
293                    CuLogLevel::Debug => "\x1b[32m",   // green
294                    CuLogLevel::Info => "\x1b[90m",    // gray
295                    CuLogLevel::Warning => "\x1b[93m", // yellow
296                    CuLogLevel::Error => "\x1b[91m",   // red
297                    CuLogLevel::Critical => "\x1b[91m",
298                };
299                let ts_color = "\x1b[34m";
300                let ts = format_timestamp(entry.time);
301                println!(
302                    "{ts_color}{ts}{reset} {level_color}[{:?}]{reset} {msg}",
303                    entry.level,
304                    ts = ts,
305                    ts_color = ts_color,
306                    level_color = level_color,
307                    reset = RESET,
308                    msg = msg
309                );
310            }
311        });
312        Ok(())
313    }
314
315    fn process_copperlist(&self, ctx: &CuContext, view: CopperListView<'_>) -> CuResult<()> {
316        let call_start = ctx.recent();
317
318        let snapshot = {
319            let mut window = lock_window(&self.window);
320            window.last_report_at.get_or_insert(call_start);
321
322            window.total_copperlists = window.total_copperlists.saturating_add(1);
323            window.window_copperlists = window.window_copperlists.saturating_add(1);
324
325            if let Some(latency) = end_to_end_latency(view.msgs()) {
326                window.end_to_end.record(latency);
327            }
328
329            for entry in view.entries() {
330                let component_index = entry.component_id.index();
331                if let Some(component_stat) = window.per_component.get_mut(component_index)
332                    && let Some(duration) = component_duration(entry.msg)
333                {
334                    component_stat.record(duration);
335                } else {
336                    debug_assert!(
337                        component_index < window.per_component.len(),
338                        "cu_logmon: component index {} out of bounds {}",
339                        component_index,
340                        window.per_component.len()
341                    );
342                }
343            }
344
345            let snapshot = self.compute_snapshot(&window, call_start);
346            if snapshot.is_some() {
347                window.reset_window(call_start);
348            }
349            snapshot
350        };
351
352        if let Some(snapshot) = snapshot {
353            let log_start = ctx.recent();
354            let use_color = cfg!(feature = "color_log");
355            let base = format!(
356                "[CL {}] rate {}.{} Hz | top4 {} | e2e p50 {}us p90 {}us p99 {}us max {}us | overhead {}us",
357                snapshot.copperlist_index,
358                snapshot.rate_whole,
359                snapshot.rate_tenths,
360                snapshot.top4,
361                snapshot.e2e_p50_us,
362                snapshot.e2e_p90_us,
363                snapshot.e2e_p99_us,
364                snapshot.e2e_max_us,
365                snapshot.overhead_us,
366            );
367            if use_color {
368                // Colored labels for readability (values stay uncolored).
369                const CL_COLOR: &str = "\x1b[94m"; // blue
370                const LABEL_COLOR: &str = "\x1b[92m"; // green for main labels
371                const SUBLABEL_COLOR: &str = "\x1b[93m"; // yellow for sublabels
372                const COMPONENT_NAME_COLOR: &str = "\x1b[38;5;208m"; // orange for component name
373                const RESET: &str = "\x1b[0m";
374                let colored = format!(
375                    "[{cl_color}CL {cl}{reset}] {label}rate{reset} {rate_whole}.{rate_tenths} Hz | {label}top4{reset} {component_color}{top4}{reset} | {label}e2e{reset} {sublabel}p50{reset} {p50}us {sublabel}p90{reset} {p90}us {sublabel}p99{reset} {p99}us {sublabel}max{reset} {max}us | {label}overhead{reset} {overhead}us",
376                    cl_color = CL_COLOR,
377                    label = LABEL_COLOR,
378                    sublabel = SUBLABEL_COLOR,
379                    component_color = COMPONENT_NAME_COLOR,
380                    reset = RESET,
381                    cl = snapshot.copperlist_index,
382                    rate_whole = snapshot.rate_whole,
383                    rate_tenths = snapshot.rate_tenths,
384                    p50 = snapshot.e2e_p50_us,
385                    p90 = snapshot.e2e_p90_us,
386                    p99 = snapshot.e2e_p99_us,
387                    max = snapshot.e2e_max_us,
388                    top4 = snapshot.top4,
389                    overhead = snapshot.overhead_us,
390                );
391                info!(ctx, "{}", &colored);
392            } else {
393                info!(ctx, "{}", &base);
394            }
395            let log_end = ctx.recent();
396            lock_window(&self.window).last_log_duration = log_end - log_start;
397        }
398
399        Ok(())
400    }
401
402    fn process_error(
403        &self,
404        component_id: ComponentId,
405        step: CuComponentState,
406        error: &CuError,
407    ) -> Decision {
408        let component_name = self.component_name(component_id);
409        error!(
410            "Component {} @ {}: Error: {}.",
411            component_name,
412            component_state_label(&step),
413            error,
414        );
415        Decision::Ignore
416    }
417
418    fn stop(&mut self, _ctx: &CuContext) -> CuResult<()> {
419        #[cfg(all(feature = "std", debug_assertions))]
420        unregister_live_log_listener();
421        Ok(())
422    }
423}