Skip to main content

seq_runtime/
report.rs

1//! At-exit report for compiled Seq programs
2//!
3//! Dumps KPIs when the program finishes, controlled by `SEQ_REPORT` env var:
4//! - Unset → no report, zero cost
5//! - `1` → human-readable to stderr
6//! - `json` → JSON to stderr
7//! - `json:/path` → JSON to file
8//!
9//! ## Feature Flag
10//!
11//! This module requires the `diagnostics` feature (enabled by default).
12//! When disabled, `report_stub.rs` provides no-op FFI symbols.
13
14#![cfg(feature = "diagnostics")]
15
16use crate::channel::{TOTAL_MESSAGES_RECEIVED, TOTAL_MESSAGES_SENT};
17use crate::memory_stats::memory_registry;
18use crate::scheduler::{PEAK_STRANDS, TOTAL_COMPLETED, TOTAL_SPAWNED, scheduler_elapsed};
19use std::io::Write;
20use std::sync::OnceLock;
21use std::sync::atomic::Ordering;
22
23// =============================================================================
24// Report Configuration (parsed from SEQ_REPORT env var)
25// =============================================================================
26
27/// Output format
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ReportFormat {
30    Human,
31    Json,
32}
33
34/// Output destination
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ReportDestination {
37    Stderr,
38    File(String),
39}
40
41/// Parsed report configuration
42#[derive(Debug, Clone)]
43pub struct ReportConfig {
44    pub format: ReportFormat,
45    pub destination: ReportDestination,
46    /// Whether to include word counts (tier 2)
47    pub include_words: bool,
48}
49
50impl ReportConfig {
51    /// Parse from SEQ_REPORT environment variable
52    pub fn from_env() -> Option<Self> {
53        let val = std::env::var("SEQ_REPORT").ok()?;
54        if val.is_empty() {
55            return None;
56        }
57
58        match val.as_str() {
59            "0" => None,
60            "1" => Some(ReportConfig {
61                format: ReportFormat::Human,
62                destination: ReportDestination::Stderr,
63                include_words: false,
64            }),
65            "words" => Some(ReportConfig {
66                format: ReportFormat::Human,
67                destination: ReportDestination::Stderr,
68                include_words: true,
69            }),
70            "json" => Some(ReportConfig {
71                format: ReportFormat::Json,
72                destination: ReportDestination::Stderr,
73                include_words: false,
74            }),
75            s if s.starts_with("json:") => {
76                let path = s[5..].to_string();
77                Some(ReportConfig {
78                    format: ReportFormat::Json,
79                    destination: ReportDestination::File(path),
80                    include_words: false,
81                })
82            }
83            _ => {
84                eprintln!("Warning: SEQ_REPORT='{}' not recognized, ignoring", val);
85                None
86            }
87        }
88    }
89}
90
91static REPORT_CONFIG: OnceLock<Option<ReportConfig>> = OnceLock::new();
92
93fn get_report_config() -> &'static Option<ReportConfig> {
94    REPORT_CONFIG.get_or_init(ReportConfig::from_env)
95}
96
97// =============================================================================
98// Report Data
99// =============================================================================
100
101/// Collected metrics for the report
102#[derive(Debug)]
103pub struct ReportData {
104    pub wall_clock_ms: u64,
105    pub total_spawned: u64,
106    pub total_completed: u64,
107    pub peak_strands: usize,
108    pub active_threads: usize,
109    pub total_arena_bytes: u64,
110    pub total_peak_arena_bytes: u64,
111    pub messages_sent: u64,
112    pub messages_received: u64,
113    pub word_counts: Option<Vec<(String, u64)>>,
114}
115
116/// Collect all metrics
117fn collect_report_data(include_words: bool) -> ReportData {
118    let wall_clock_ms = scheduler_elapsed()
119        .map(|d| d.as_millis() as u64)
120        .unwrap_or(0);
121
122    let mem_stats = memory_registry().aggregate_stats();
123
124    let word_counts = if include_words {
125        read_word_counts()
126    } else {
127        None
128    };
129
130    ReportData {
131        wall_clock_ms,
132        total_spawned: TOTAL_SPAWNED.load(Ordering::Relaxed),
133        total_completed: TOTAL_COMPLETED.load(Ordering::Relaxed),
134        peak_strands: PEAK_STRANDS.load(Ordering::Relaxed),
135        active_threads: mem_stats.active_threads,
136        total_arena_bytes: mem_stats.total_arena_bytes,
137        total_peak_arena_bytes: mem_stats.total_peak_arena_bytes,
138        messages_sent: TOTAL_MESSAGES_SENT.load(Ordering::Relaxed),
139        messages_received: TOTAL_MESSAGES_RECEIVED.load(Ordering::Relaxed),
140        word_counts,
141    }
142}
143
144// =============================================================================
145// Formatting
146// =============================================================================
147
148fn format_human(data: &ReportData) -> String {
149    let mut out = String::new();
150    out.push_str("=== SEQ REPORT ===\n");
151    out.push_str(&format!("Wall clock:      {} ms\n", data.wall_clock_ms));
152    out.push_str(&format!("Strands spawned: {}\n", data.total_spawned));
153    out.push_str(&format!("Strands done:    {}\n", data.total_completed));
154    out.push_str(&format!("Peak strands:    {}\n", data.peak_strands));
155    out.push_str(&format!("Worker threads:  {}\n", data.active_threads));
156    out.push_str(&format!(
157        "Arena current:   {} bytes\n",
158        data.total_arena_bytes
159    ));
160    out.push_str(&format!(
161        "Arena peak:      {} bytes\n",
162        data.total_peak_arena_bytes
163    ));
164    out.push_str(&format!("Messages sent:   {}\n", data.messages_sent));
165    out.push_str(&format!("Messages recv:   {}\n", data.messages_received));
166
167    if let Some(ref counts) = data.word_counts {
168        out.push_str("\n--- Word Call Counts ---\n");
169        for (name, count) in counts {
170            out.push_str(&format!("  {:30} {}\n", name, count));
171        }
172    }
173
174    out.push_str("==================\n");
175    out
176}
177
178#[cfg(feature = "report-json")]
179fn format_json(data: &ReportData) -> String {
180    let mut map = serde_json::Map::new();
181    map.insert(
182        "wall_clock_ms".into(),
183        serde_json::Value::Number(data.wall_clock_ms.into()),
184    );
185    map.insert(
186        "strands_spawned".into(),
187        serde_json::Value::Number(data.total_spawned.into()),
188    );
189    map.insert(
190        "strands_completed".into(),
191        serde_json::Value::Number(data.total_completed.into()),
192    );
193    map.insert(
194        "peak_strands".into(),
195        serde_json::Value::Number((data.peak_strands as u64).into()),
196    );
197    map.insert(
198        "worker_threads".into(),
199        serde_json::Value::Number((data.active_threads as u64).into()),
200    );
201    map.insert(
202        "arena_bytes".into(),
203        serde_json::Value::Number(data.total_arena_bytes.into()),
204    );
205    map.insert(
206        "arena_peak_bytes".into(),
207        serde_json::Value::Number(data.total_peak_arena_bytes.into()),
208    );
209    map.insert(
210        "messages_sent".into(),
211        serde_json::Value::Number(data.messages_sent.into()),
212    );
213    map.insert(
214        "messages_received".into(),
215        serde_json::Value::Number(data.messages_received.into()),
216    );
217
218    if let Some(ref counts) = data.word_counts {
219        let word_map: serde_json::Map<String, serde_json::Value> = counts
220            .iter()
221            .map(|(name, count)| (name.clone(), serde_json::Value::Number((*count).into())))
222            .collect();
223        map.insert("word_counts".into(), serde_json::Value::Object(word_map));
224    }
225
226    let obj = serde_json::Value::Object(map);
227    serde_json::to_string(&obj).unwrap_or_else(|_| "{}".to_string())
228}
229
230#[cfg(not(feature = "report-json"))]
231fn format_json(_data: &ReportData) -> String {
232    eprintln!(
233        "Warning: SEQ_REPORT=json requires the 'report-json' feature. Falling back to human format."
234    );
235    format_human(_data)
236}
237
238// =============================================================================
239// Tier 2: Word Count Data (populated by patch_seq_report_init)
240// =============================================================================
241
242/// Pointers to instrumentation data registered by compiled binary
243struct WordCountData {
244    counters: *const u64,
245    names: *const *const u8,
246    count: usize,
247}
248
249// Safety: the pointers are to static data in the compiled binary
250unsafe impl Send for WordCountData {}
251unsafe impl Sync for WordCountData {}
252
253static WORD_COUNT_DATA: OnceLock<WordCountData> = OnceLock::new();
254
255fn read_word_counts() -> Option<Vec<(String, u64)>> {
256    let data = WORD_COUNT_DATA.get()?;
257    let mut counts = Vec::with_capacity(data.count);
258
259    unsafe {
260        for i in 0..data.count {
261            let counter_val = std::ptr::read_volatile(data.counters.add(i));
262            let name_ptr = *data.names.add(i);
263            let name = std::ffi::CStr::from_ptr(name_ptr as *const i8)
264                .to_string_lossy()
265                .into_owned();
266            counts.push((name, counter_val));
267        }
268    }
269
270    // Sort by count descending
271    counts.sort_by(|a, b| b.1.cmp(&a.1));
272    Some(counts)
273}
274
275// =============================================================================
276// Emit
277// =============================================================================
278
279fn emit_report() {
280    let config = match get_report_config() {
281        Some(c) => c,
282        None => return,
283    };
284
285    let data = collect_report_data(config.include_words);
286
287    let output = match config.format {
288        ReportFormat::Human => format_human(&data),
289        ReportFormat::Json => format_json(&data),
290    };
291
292    match &config.destination {
293        ReportDestination::Stderr => {
294            let _ = std::io::stderr().write_all(output.as_bytes());
295        }
296        ReportDestination::File(path) => {
297            if let Ok(mut f) = std::fs::File::create(path) {
298                let _ = f.write_all(output.as_bytes());
299            } else {
300                eprintln!("Warning: could not write report to {}", path);
301                let _ = std::io::stderr().write_all(output.as_bytes());
302            }
303        }
304    }
305}
306
307// =============================================================================
308// FFI Entry Points
309// =============================================================================
310
311/// At-exit report — called from generated main after scheduler_run
312///
313/// # Safety
314/// Safe to call from any context.
315#[unsafe(no_mangle)]
316pub unsafe extern "C" fn patch_seq_report() {
317    emit_report();
318}
319
320/// Register instrumentation data from compiled binary (tier 2)
321///
322/// # Safety
323/// - `counters` must point to a valid array of `count` i64 values
324/// - `names` must point to a valid array of `count` C string pointers
325/// - Both must remain valid for the program's lifetime (they're static globals)
326#[unsafe(no_mangle)]
327pub unsafe extern "C" fn patch_seq_report_init(
328    counters: *const u64,
329    names: *const *const u8,
330    count: i64,
331) {
332    if counters.is_null() || names.is_null() || count <= 0 {
333        return;
334    }
335    let _ = WORD_COUNT_DATA.set(WordCountData {
336        counters,
337        names,
338        count: count as usize,
339    });
340}
341
342// =============================================================================
343// Tests
344// =============================================================================
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn test_config_parse_none() {
352        // When env var is not set, from_env returns None
353        // We can't easily unset env vars in tests, so test the logic directly
354        assert!(ReportConfig::from_env().is_none() || ReportConfig::from_env().is_some());
355    }
356
357    #[test]
358    fn test_config_parse_variants() {
359        // Test parsing logic by checking the match arms directly
360        let test_cases = vec![
361            ("0", None),
362            (
363                "1",
364                Some((ReportFormat::Human, ReportDestination::Stderr, false)),
365            ),
366            (
367                "words",
368                Some((ReportFormat::Human, ReportDestination::Stderr, true)),
369            ),
370            (
371                "json",
372                Some((ReportFormat::Json, ReportDestination::Stderr, false)),
373            ),
374            (
375                "json:/tmp/report.json",
376                Some((
377                    ReportFormat::Json,
378                    ReportDestination::File("/tmp/report.json".to_string()),
379                    false,
380                )),
381            ),
382        ];
383
384        for (input, expected) in test_cases {
385            let result = match input {
386                "0" => None,
387                "1" => Some(ReportConfig {
388                    format: ReportFormat::Human,
389                    destination: ReportDestination::Stderr,
390                    include_words: false,
391                }),
392                "words" => Some(ReportConfig {
393                    format: ReportFormat::Human,
394                    destination: ReportDestination::Stderr,
395                    include_words: true,
396                }),
397                "json" => Some(ReportConfig {
398                    format: ReportFormat::Json,
399                    destination: ReportDestination::Stderr,
400                    include_words: false,
401                }),
402                s if s.starts_with("json:") => Some(ReportConfig {
403                    format: ReportFormat::Json,
404                    destination: ReportDestination::File(s[5..].to_string()),
405                    include_words: false,
406                }),
407                _ => None,
408            };
409
410            match (result, expected) {
411                (None, None) => {}
412                (Some(r), Some((fmt, dest, words))) => {
413                    assert_eq!(r.format, fmt, "format mismatch for input '{}'", input);
414                    assert_eq!(
415                        r.destination, dest,
416                        "destination mismatch for input '{}'",
417                        input
418                    );
419                    assert_eq!(
420                        r.include_words, words,
421                        "include_words mismatch for input '{}'",
422                        input
423                    );
424                }
425                _ => panic!("Mismatch for input '{}'", input),
426            }
427        }
428    }
429
430    #[test]
431    fn test_collect_report_data() {
432        let data = collect_report_data(false);
433        // Basic sanity: these should not panic and return reasonable values
434        assert!(data.wall_clock_ms < 1_000_000_000); // less than ~11 days
435        assert!(data.peak_strands < 1_000_000);
436        assert!(data.word_counts.is_none());
437    }
438
439    #[test]
440    fn test_format_human() {
441        let data = ReportData {
442            wall_clock_ms: 42,
443            total_spawned: 10,
444            total_completed: 9,
445            peak_strands: 5,
446            active_threads: 2,
447            total_arena_bytes: 1024,
448            total_peak_arena_bytes: 2048,
449            messages_sent: 100,
450            messages_received: 99,
451            word_counts: None,
452        };
453        let output = format_human(&data);
454        assert!(output.contains("SEQ REPORT"));
455        assert!(output.contains("42 ms"));
456        assert!(output.contains("Strands spawned: 10"));
457        assert!(output.contains("Arena peak:      2048 bytes"));
458    }
459
460    #[test]
461    fn test_format_human_with_word_counts() {
462        let data = ReportData {
463            wall_clock_ms: 100,
464            total_spawned: 1,
465            total_completed: 1,
466            peak_strands: 1,
467            active_threads: 1,
468            total_arena_bytes: 0,
469            total_peak_arena_bytes: 0,
470            messages_sent: 0,
471            messages_received: 0,
472            word_counts: Some(vec![("main".to_string(), 1), ("helper".to_string(), 42)]),
473        };
474        let output = format_human(&data);
475        assert!(output.contains("Word Call Counts"));
476        assert!(output.contains("main"));
477        assert!(output.contains("helper"));
478    }
479
480    #[cfg(feature = "report-json")]
481    #[test]
482    fn test_format_json() {
483        let data = ReportData {
484            wall_clock_ms: 42,
485            total_spawned: 10,
486            total_completed: 9,
487            peak_strands: 5,
488            active_threads: 2,
489            total_arena_bytes: 1024,
490            total_peak_arena_bytes: 2048,
491            messages_sent: 100,
492            messages_received: 99,
493            word_counts: None,
494        };
495        let output = format_json(&data);
496        assert!(output.contains("\"wall_clock_ms\":42"));
497        assert!(output.contains("\"strands_spawned\":10"));
498        assert!(output.contains("\"arena_peak_bytes\":2048"));
499    }
500
501    #[test]
502    fn test_emit_report_noop_when_disabled() {
503        // When SEQ_REPORT is not set, emit_report should be a no-op
504        emit_report();
505        // If we get here, it didn't panic
506    }
507}