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;