Skip to main content

strontium_core/
trace.rs

1use serde::{Serialize, Serializer};
2use std::time::Duration;
3
4#[derive(Debug, Clone)]
5pub struct TraceConfig {
6    pub enabled: bool,
7    pub max_events: Option<usize>,
8    pub max_bytes: Option<usize>,
9    pub sampling_rate: f64,
10}
11
12impl Default for TraceConfig {
13    fn default() -> Self {
14        Self {
15            enabled: false,
16            max_events: None,
17            max_bytes: None,
18            sampling_rate: 1.0,
19        }
20    }
21}
22
23#[derive(Debug, Clone)]
24pub enum TraceEvent {
25    TaskPolled {
26        task_id: usize,
27        virtual_time_ms: u64,
28    },
29    TimerFired {
30        deadline_ms: u64,
31        count: usize,
32    },
33}
34
35#[derive(Debug, Clone, Default, Serialize)]
36pub struct TraceMetadata {
37    pub events_captured: usize,
38    pub events_dropped: usize,
39    pub truncated: bool,
40}
41
42#[derive(Clone)]
43pub struct TraceBuffer {
44    pub(crate) config: TraceConfig,
45    events: Vec<TraceEvent>,
46    total_attempted: usize,
47    sample_counter: usize,
48}
49
50impl TraceBuffer {
51    pub fn new(config: TraceConfig) -> Self {
52        Self {
53            config,
54            events: Vec::new(),
55            total_attempted: 0,
56            sample_counter: 0,
57        }
58    }
59
60    pub fn record(&mut self, event: TraceEvent) {
61        if !self.config.enabled {
62            return;
63        }
64        self.total_attempted += 1;
65
66        if self.config.sampling_rate < 1.0 {
67            self.sample_counter += 1;
68            let period = (1.0 / self.config.sampling_rate).ceil() as usize;
69            if self.sample_counter % period != 0 {
70                return;
71            }
72        }
73
74        if let Some(max) = self.config.max_events {
75            if self.events.len() >= max {
76                return;
77            }
78        }
79
80        self.events.push(event);
81    }
82
83    pub fn events(&self) -> &[TraceEvent] {
84        &self.events
85    }
86
87    pub fn metadata(&self) -> TraceMetadata {
88        let cap = self.config.max_events;
89        let truncated = cap.map(|m| self.total_attempted > m).unwrap_or(false);
90        TraceMetadata {
91            events_captured: self.events.len(),
92            events_dropped: self.total_attempted.saturating_sub(self.events.len()),
93            truncated,
94        }
95    }
96}
97
98#[derive(Debug, Clone, Serialize)]
99pub struct ReproBundle {
100    pub seed: u64,
101    #[serde(rename = "virtual_time_ms", serialize_with = "duration_as_millis")]
102    pub virtual_time: Duration,
103    pub steps: u64,
104    pub error: String,
105    pub decision_log: Vec<usize>,
106    #[serde(flatten)]
107    pub trace_metadata: TraceMetadata,
108}
109
110fn duration_as_millis<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
111    s.serialize_u64(d.as_millis() as u64)
112}
113
114impl ReproBundle {
115    pub fn catalog_name(&self) -> String {
116        let err_slug = self
117            .error
118            .chars()
119            .take(20)
120            .map(|c| if c.is_alphanumeric() { c } else { '-' })
121            .collect::<String>()
122            .to_lowercase();
123        format!("failure-{}-{}.json", err_slug, self.seed)
124    }
125
126    pub fn to_json(&self) -> std::io::Result<String> {
127        serde_json::to_string(self).map_err(std::io::Error::other)
128    }
129
130    pub fn write_to_file(&self) -> std::io::Result<std::path::PathBuf> {
131        let base_dir = std::env::var("CARGO_TARGET_DIR")
132            .ok()
133            .map(std::path::PathBuf::from)
134            .unwrap_or_else(|| std::path::PathBuf::from("."));
135
136        let repro_dir = base_dir.join("sim-repro");
137        let catalog_dir = base_dir.join("sim-catalog");
138
139        std::fs::create_dir_all(&repro_dir)?;
140        std::fs::create_dir_all(&catalog_dir)?;
141
142        let json = self.to_json()?;
143
144        let repro_path = repro_dir.join("repro_bundle.json");
145        std::fs::write(&repro_path, &json)?;
146
147        let catalog_path = catalog_dir.join(self.catalog_name());
148        if !catalog_path.exists() {
149            std::fs::write(&catalog_path, &json)?;
150        }
151
152        Ok(repro_path)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::{ReproBundle, TraceBuffer, TraceConfig, TraceEvent, TraceMetadata};
159    use std::time::Duration;
160
161    #[test]
162    fn max_events_truncates_trace_metadata() {
163        let mut trace = TraceBuffer::new(TraceConfig {
164            enabled: true,
165            max_events: Some(1),
166            max_bytes: None,
167            sampling_rate: 1.0,
168        });
169
170        trace.record(TraceEvent::TaskPolled {
171            task_id: 1,
172            virtual_time_ms: 0,
173        });
174        trace.record(TraceEvent::TaskPolled {
175            task_id: 2,
176            virtual_time_ms: 1,
177        });
178
179        let metadata = trace.metadata();
180        assert_eq!(metadata.events_captured, 1);
181        assert_eq!(metadata.events_dropped, 1);
182        assert!(metadata.truncated);
183    }
184
185    #[test]
186    fn repro_bundle_json_is_valid_and_complete() {
187        let bundle = ReproBundle {
188            seed: 42,
189            virtual_time: Duration::from_millis(100),
190            steps: 5,
191            error: "invariant violated: x > 0".to_string(),
192            decision_log: vec![1, 2, 3],
193            trace_metadata: TraceMetadata {
194                events_captured: 10,
195                events_dropped: 2,
196                truncated: true,
197            },
198        };
199
200        let json = bundle.to_json().expect("serialize");
201        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
202
203        assert_eq!(v["seed"], 42);
204        assert_eq!(v["virtual_time_ms"], 100);
205        assert_eq!(v["steps"], 5);
206        assert_eq!(v["error"], "invariant violated: x > 0");
207        assert_eq!(v["decision_log"], serde_json::json!([1, 2, 3]));
208        assert_eq!(v["events_captured"], 10);
209        assert_eq!(v["events_dropped"], 2);
210        assert_eq!(v["truncated"], true);
211    }
212
213    #[test]
214    fn repro_bundle_json_escapes_error_string() {
215        let bundle = ReproBundle {
216            seed: 1,
217            virtual_time: Duration::ZERO,
218            steps: 0,
219            error: r#"msg with "quotes" and \backslash"#.to_string(),
220            decision_log: vec![],
221            trace_metadata: TraceMetadata::default(),
222        };
223
224        let json = bundle.to_json().expect("serialize");
225        // Must round-trip cleanly through a JSON parser
226        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
227        assert_eq!(v["error"], bundle.error);
228    }
229}