Skip to main content

nmbrs_runtime/readouts/builtins/
trace.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `trace` — a minimal diagnostic readout that surfaces
5//! every relevant [`ReadoutContext`] field as text for
6//! whatever event triggered it.
7//!
8//! Two purposes:
9//!
10//! 1. **Test affordance.** Bind it to events in unit
11//!    tests and assert that events fire with the expected
12//!    context. Drops the burden of crafting bespoke
13//!    assertion helpers per surface.
14//! 2. **Reference implementation.** This is the smallest
15//!    possible custom renderer — every method an author
16//!    might want to read from `ReadoutContext` is read
17//!    here, with one line of formatted output per field.
18//!    A user building a fully custom render format can
19//!    copy this file and edit it.
20//!
21//! `trace` does not branch on LOD or content mode — it
22//! emits the same fields at every combination. This
23//! intentionally violates SRD-63 §3.3's monotonicity
24//! guidance for production readouts; `trace` is a
25//! diagnostic, not a user-facing render, and uniformity
26//! across LODs is what makes it useful for testing.
27
28use std::fmt::Write as _;
29
30use crate::lifecycle::SubjectKind;
31use crate::readouts::buf::ReadoutBuf;
32use crate::readouts::context::ReadoutContext;
33use crate::readouts::readout::{ContentMode, Lod, Readout, ReadoutOptions};
34
35pub struct Trace;
36
37impl Readout for Trace {
38    fn name(&self) -> &'static str {
39        "trace"
40    }
41    fn accepts(&self) -> &'static [SubjectKind] {
42        &[
43            SubjectKind::Session,
44            SubjectKind::Phase,
45            SubjectKind::Iteration,
46            SubjectKind::Scope,
47        ]
48    }
49
50    fn render(
51        &self,
52        ctx: &dyn ReadoutContext,
53        lod: Lod,
54        mode: ContentMode,
55        _opts: &ReadoutOptions,
56        out: &mut dyn ReadoutBuf,
57    ) -> usize {
58        // Push 7: in Explanation mode, dump the field
59        // *schema* (one line per field: `name: kind`)
60        // rather than the live values. Same call shape
61        // as Value — the user gets to see what every
62        // field means without changing display position.
63        if matches!(mode, ContentMode::Explanation) {
64            return render_explanation(ctx, out);
65        }
66        let mut tmp = String::with_capacity(512);
67
68        // Header: event slot + LOD + mode. Three pieces of
69        // metadata that aren't part of `ReadoutContext`
70        // itself — they describe the call shape.
71        let _ = write!(
72            &mut tmp,
73            "event={slot} lod={lod:?} mode={mode:?}",
74            slot = ctx.event().slot_name(),
75        );
76
77        // Context fields, one per line under the header.
78        // Order kept stable across runs so test assertions
79        // can pin against it.
80        let _ = write!(&mut tmp, "\n  refresh_tick={t}", t = ctx.refresh_tick(),);
81        let _ = write!(&mut tmp, "\n  phase_name={n:?}", n = ctx.subject_name(),);
82        let _ = write!(&mut tmp, "\n  activity_name={n:?}", n = ctx.activity_name(),);
83        match ctx.subject_seq() {
84            Some((i, t)) => {
85                let _ = write!(&mut tmp, "\n  phase_seq=({i}/{t})");
86            }
87            None => {
88                let _ = write!(&mut tmp, "\n  phase_seq=None");
89            }
90        }
91        let _ = write!(&mut tmp, "\n  phase_labels={l:?}", l = ctx.subject_labels(),);
92        let _ = write!(
93            &mut tmp,
94            "\n  cycles={c}/{t}",
95            c = ctx.cycles_completed(),
96            t = ctx.cycles_total(),
97        );
98        let _ = write!(
99            &mut tmp,
100            "\n  ops_started={s} ops_ok={ok} errors={e} retries={r}",
101            s = ctx.ops_started(),
102            ok = ctx.ops_ok(),
103            e = ctx.errors(),
104            r = ctx.retries(),
105        );
106        let _ = write!(&mut tmp, "\n  concurrency={c}", c = ctx.concurrency(),);
107        let _ = write!(&mut tmp, "\n  consumed={c}", c = ctx.consumed(),);
108        let _ = write!(&mut tmp, "\n  elapsed_secs={e:.3}", e = ctx.elapsed_secs(),);
109        match ctx.eta_secs() {
110            Some(s) => {
111                let _ = write!(&mut tmp, "\n  eta_secs={s:.3}");
112            }
113            None => {
114                let _ = write!(&mut tmp, "\n  eta_secs=None");
115            }
116        }
117        let chips = ctx.status_metric_chips();
118        if !chips.is_empty() {
119            let _ = write!(&mut tmp, "\n  status_metric_chips={chips:?}");
120        }
121        let adapter = ctx.adapter_counters_text();
122        if !adapter.is_empty() {
123            let _ = write!(&mut tmp, "\n  adapter_counters_text={adapter:?}");
124        }
125        let batch = ctx.batch_info_text();
126        if !batch.is_empty() {
127            let _ = write!(&mut tmp, "\n  batch_info_text={batch:?}");
128        }
129        let _ = write!(
130            &mut tmp,
131            "\n  depth_indent_len={d}",
132            d = ctx.depth_indent().len(),
133        );
134        let _ = write!(&mut tmp, "\n  use_color={c}", c = ctx.use_color(),);
135
136        let len = tmp.len();
137        let _ = out.write_str(&tmp);
138        len
139    }
140}
141
142/// Schema dump for Explanation mode. Same field list as
143/// the value render, but each row reads
144/// `name: <semantic type>` instead of `name=<value>`.
145fn render_explanation(ctx: &dyn ReadoutContext, out: &mut dyn ReadoutBuf) -> usize {
146    let s = "event=on_<event-slot> lod=<density> mode=<value|explanation>\n  \
147refresh_tick: monotonic counter advanced once per refresh fire\n  \
148phase_name: bare phase identifier (no coord suffix)\n  \
149activity_name: full activity name with leaf coord\n  \
150phase_seq: pre-map (idx, total) numbering\n  \
151phase_labels: root-first scope coordinate path\n  \
152cycles: completed/total\n  \
153ops_started ops_ok errors retries: cumulative counters\n  \
154concurrency: effective fiber count\n  \
155consumed: items pulled from the source factory\n  \
156elapsed_secs: wallclock since phase start\n  \
157eta_secs: estimated remaining time, when computable\n  \
158status_metric_chips: workload-emphasised metric tail\n  \
159adapter_counters_text: per-dispenser counter chips\n  \
160batch_info_text: rows-per-batch summary\n  \
161depth_indent_len: scope-tree indent width\n  \
162use_color: surface accepts ANSI styling"
163        .to_string();
164    let _ = out.write_str(&s);
165    let _ = ctx; // schema doesn't depend on context
166    s.len()
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::lifecycle::EventType;
173    use crate::readouts::buf::StringBuf;
174
175    #[derive(Default)]
176    struct TestCtx {
177        event: Option<EventType>,
178        refresh_tick: u64,
179        phase_name: String,
180        activity_name: Option<String>,
181        phase_seq: Option<(usize, usize)>,
182        phase_labels: String,
183        cycles_completed: u64,
184        cycles_total: u64,
185        ops_started: u64,
186        ops_ok: u64,
187        errors: u64,
188        retries: u64,
189        concurrency: usize,
190        consumed: u64,
191        elapsed_secs: f64,
192        eta_secs: Option<f64>,
193        chips: String,
194        adapter: String,
195        batch: String,
196        depth_indent: String,
197        use_color: bool,
198    }
199
200    impl ReadoutContext for TestCtx {
201        fn subject_name(&self) -> &str {
202            &self.phase_name
203        }
204        fn activity_name(&self) -> &str {
205            self.activity_name.as_deref().unwrap_or(&self.phase_name)
206        }
207        fn subject_seq(&self) -> Option<(usize, usize)> {
208            self.phase_seq
209        }
210        fn subject_labels(&self) -> &str {
211            &self.phase_labels
212        }
213        fn cycles_completed(&self) -> u64 {
214            self.cycles_completed
215        }
216        fn cycles_total(&self) -> u64 {
217            self.cycles_total
218        }
219        fn ops_started(&self) -> u64 {
220            self.ops_started
221        }
222        fn ops_ok(&self) -> u64 {
223            self.ops_ok
224        }
225        fn errors(&self) -> u64 {
226            self.errors
227        }
228        fn retries(&self) -> u64 {
229            self.retries
230        }
231        fn concurrency(&self) -> usize {
232            self.concurrency
233        }
234        fn elapsed_secs(&self) -> f64 {
235            self.elapsed_secs
236        }
237        fn eta_secs(&self) -> Option<f64> {
238            self.eta_secs
239        }
240        fn consumed(&self) -> u64 {
241            self.consumed
242        }
243        fn status_metric_chips(&self) -> String {
244            self.chips.clone()
245        }
246        fn adapter_counters_text(&self) -> String {
247            self.adapter.clone()
248        }
249        fn batch_info_text(&self) -> String {
250            self.batch.clone()
251        }
252        fn depth_indent(&self) -> &str {
253            &self.depth_indent
254        }
255        fn use_color(&self) -> bool {
256            self.use_color
257        }
258        fn event(&self) -> EventType {
259            self.event.unwrap_or(EventType::PhaseEnd)
260        }
261        fn refresh_tick(&self) -> u64 {
262            self.refresh_tick
263        }
264    }
265
266    fn render(ctx: &TestCtx) -> String {
267        let mut s = String::new();
268        let mut buf = StringBuf::new(&mut s);
269        Trace.render(
270            ctx,
271            Lod::Labeled,
272            ContentMode::Value,
273            &ReadoutOptions::new(),
274            &mut buf,
275        );
276        s
277    }
278
279    #[test]
280    fn covers_every_event_class() {
281        // Every Event variant must round-trip through the
282        // header. If a future Event variant gets added and
283        // the reverse-mapping forgets it, this test fails.
284        for ev in [
285            EventType::SessionStart,
286            EventType::SessionEnd,
287            EventType::PhaseStart,
288            EventType::PhaseEnd,
289            EventType::EachStart,
290            EventType::EachEnd,
291            EventType::ScopeStart,
292            EventType::ScopeEnd,
293            EventType::Update,
294        ] {
295            let ctx = TestCtx {
296                event: Some(ev),
297                phase_name: "p".into(),
298                ..Default::default()
299            };
300            let out = render(&ctx);
301            assert!(
302                out.starts_with(&format!("event={}", ev.slot_name())),
303                "event {ev:?} not surfaced in trace header: {out}"
304            );
305        }
306    }
307
308    #[test]
309    fn dumps_all_listed_fields() {
310        let ctx = TestCtx {
311            event: Some(EventType::PhaseEnd),
312            refresh_tick: 7,
313            phase_name: "ann_query".into(),
314            activity_name: Some("ann_query (k=10)".into()),
315            phase_seq: Some((3, 9)),
316            phase_labels: "(profile=alpha)".into(),
317            cycles_completed: 100,
318            cycles_total: 100,
319            ops_started: 100,
320            ops_ok: 99,
321            errors: 1,
322            retries: 0,
323            concurrency: 4,
324            consumed: 100,
325            elapsed_secs: 1.234,
326            eta_secs: Some(0.5),
327            chips: " recall_at_10:79.62%".into(),
328            adapter: " rows/s=12.5K".into(),
329            batch: " r/b=12.5".into(),
330            depth_indent: "    ".into(), // 4 spaces
331            use_color: true,
332        };
333        let out = render(&ctx);
334        // Spot-check every field surfaces.
335        for needle in [
336            "event=on_phase_end",
337            "refresh_tick=7",
338            "phase_name=\"ann_query\"",
339            "activity_name=\"ann_query (k=10)\"",
340            "phase_seq=(3/9)",
341            "phase_labels=\"(profile=alpha)\"",
342            "cycles=100/100",
343            "ops_started=100 ops_ok=99 errors=1 retries=0",
344            "concurrency=4",
345            "consumed=100",
346            "elapsed_secs=1.234",
347            "eta_secs=0.500",
348            "status_metric_chips=\" recall_at_10:79.62%\"",
349            "adapter_counters_text=\" rows/s=12.5K\"",
350            "batch_info_text=\" r/b=12.5\"",
351            "depth_indent_len=4",
352            "use_color=true",
353        ] {
354            assert!(
355                out.contains(needle),
356                "trace missing {needle:?} — actual: {out}"
357            );
358        }
359    }
360
361    #[test]
362    fn omits_empty_string_fields() {
363        let ctx = TestCtx {
364            phase_name: "x".into(),
365            ..Default::default()
366        };
367        let out = render(&ctx);
368        // Empty chips / adapter / batch suppressed.
369        assert!(
370            !out.contains("status_metric_chips="),
371            "empty chips should not surface: {out}"
372        );
373        assert!(
374            !out.contains("adapter_counters_text="),
375            "empty adapter text should not surface: {out}"
376        );
377        assert!(
378            !out.contains("batch_info_text="),
379            "empty batch text should not surface: {out}"
380        );
381    }
382}