1use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::sync::{Arc, RwLock};
9use std::time::Duration;
10
11pub const TRACE_EVENT_SCHEMA: &str = "a3s.trace_event.v1";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TraceEventKind {
16 ToolExecution,
17 ProgramExecution,
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct TraceEvent {
22 pub schema: String,
23 pub kind: TraceEventKind,
24 pub name: String,
25 pub success: bool,
26 pub exit_code: i32,
27 pub duration_ms: u64,
28 pub output_bytes: usize,
29 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 pub metadata_keys: Vec<String>,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub artifact_uris: Vec<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub details: Option<serde_json::Value>,
35}
36
37impl TraceEvent {
38 pub fn tool_execution(
39 name: impl Into<String>,
40 success: bool,
41 exit_code: i32,
42 duration: Duration,
43 output_bytes: usize,
44 metadata: Option<&serde_json::Value>,
45 ) -> Self {
46 Self {
47 schema: TRACE_EVENT_SCHEMA.to_string(),
48 kind: TraceEventKind::ToolExecution,
49 name: name.into(),
50 success,
51 exit_code,
52 duration_ms: duration.as_millis().min(u128::from(u64::MAX)) as u64,
53 output_bytes,
54 metadata_keys: metadata_keys(metadata),
55 artifact_uris: artifact_uris(metadata),
56 details: None,
57 }
58 }
59
60 pub fn program_execution(
61 name: impl Into<String>,
62 success: bool,
63 exit_code: i32,
64 duration: Duration,
65 output_bytes: usize,
66 metadata: Option<&serde_json::Value>,
67 ) -> Self {
68 let details = metadata
69 .and_then(|metadata| metadata.get("trace"))
70 .map(program_trace_summary);
71
72 Self {
73 schema: TRACE_EVENT_SCHEMA.to_string(),
74 kind: TraceEventKind::ProgramExecution,
75 name: name.into(),
76 success,
77 exit_code,
78 duration_ms: duration.as_millis().min(u128::from(u64::MAX)) as u64,
79 output_bytes,
80 metadata_keys: metadata_keys(metadata),
81 artifact_uris: artifact_uris(metadata),
82 details,
83 }
84 }
85}
86
87pub trait TraceSink: Send + Sync {
88 fn record(&self, event: TraceEvent);
89}
90
91#[derive(Debug, Clone, Default)]
92pub struct InMemoryTraceSink {
93 events: Arc<RwLock<VecDeque<TraceEvent>>>,
94 max_events: Option<usize>,
99}
100
101impl InMemoryTraceSink {
102 pub fn new() -> Self {
104 Self::default()
105 }
106
107 pub fn with_max_events(max_events: usize) -> Self {
109 Self {
110 events: Arc::new(RwLock::new(VecDeque::with_capacity(max_events.min(1024)))),
111 max_events: Some(max_events),
112 }
113 }
114
115 pub fn events(&self) -> Vec<TraceEvent> {
116 self.events.read().unwrap().iter().cloned().collect()
117 }
118
119 pub fn replace_events(&self, events: Vec<TraceEvent>) {
123 let mut retained = VecDeque::with_capacity(
124 self.max_events
125 .map(|cap| events.len().min(cap))
126 .unwrap_or(events.len()),
127 );
128 let skip = self
129 .max_events
130 .map(|cap| events.len().saturating_sub(cap))
131 .unwrap_or(0);
132 retained.extend(events.into_iter().skip(skip));
133 *self.events.write().unwrap() = retained;
134 }
135
136 pub fn clear(&self) {
137 self.events.write().unwrap().clear();
138 }
139}
140
141impl TraceSink for InMemoryTraceSink {
142 fn record(&self, event: TraceEvent) {
143 let mut events = self.events.write().unwrap();
144 events.push_back(event);
145 if let Some(cap) = self.max_events {
148 while events.len() > cap {
149 events.pop_front();
150 }
151 }
152 }
153}
154
155#[derive(Debug, Clone, Copy, Default)]
156pub struct NoopTraceSink;
157
158impl TraceSink for NoopTraceSink {
159 fn record(&self, _event: TraceEvent) {}
160}
161
162fn metadata_keys(metadata: Option<&serde_json::Value>) -> Vec<String> {
163 let Some(serde_json::Value::Object(object)) = metadata else {
164 return Vec::new();
165 };
166
167 let mut keys = object.keys().cloned().collect::<Vec<_>>();
168 keys.sort();
169 keys
170}
171
172fn artifact_uris(metadata: Option<&serde_json::Value>) -> Vec<String> {
173 let mut uris = Vec::new();
174 if let Some(metadata) = metadata {
175 collect_artifact_uris(metadata, &mut uris);
176 }
177 uris.sort();
178 uris.dedup();
179 uris
180}
181
182fn collect_artifact_uris(value: &serde_json::Value, uris: &mut Vec<String>) {
183 match value {
184 serde_json::Value::Object(object) => {
185 if let Some(uri) = object.get("artifact_uri").and_then(|value| value.as_str()) {
186 uris.push(uri.to_string());
187 }
188 for value in object.values() {
189 collect_artifact_uris(value, uris);
190 }
191 }
192 serde_json::Value::Array(items) => {
193 for value in items {
194 collect_artifact_uris(value, uris);
195 }
196 }
197 _ => {}
198 }
199}
200
201fn program_trace_summary(trace: &serde_json::Value) -> serde_json::Value {
202 serde_json::json!({
203 "program_name": trace.get("program_name").cloned().unwrap_or_default(),
204 "success": trace.get("success").cloned().unwrap_or_default(),
205 "step_count": trace.get("step_count").cloned().unwrap_or_default(),
206 "failed_steps": trace.get("failed_steps").cloned().unwrap_or_default(),
207 })
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn in_memory_trace_sink_records_events() {
216 let sink = InMemoryTraceSink::default();
217 sink.record(TraceEvent::tool_execution(
218 "read",
219 true,
220 0,
221 Duration::from_millis(7),
222 12,
223 Some(&serde_json::json!({
224 "artifact": {
225 "artifact_uri": "a3s://tool-output/read/abc"
226 },
227 "file_path": "src/lib.rs"
228 })),
229 ));
230
231 let events = sink.events();
232
233 assert_eq!(events.len(), 1);
234 assert_eq!(events[0].schema, TRACE_EVENT_SCHEMA);
235 assert_eq!(events[0].kind, TraceEventKind::ToolExecution);
236 assert_eq!(events[0].metadata_keys, vec!["artifact", "file_path"]);
237 assert_eq!(events[0].artifact_uris, vec!["a3s://tool-output/read/abc"]);
238 }
239
240 #[test]
241 fn program_trace_event_stores_compact_summary() {
242 let event = TraceEvent::program_execution(
243 "program",
244 true,
245 0,
246 Duration::from_millis(3),
247 42,
248 Some(&serde_json::json!({
249 "trace": {
250 "program_name": "program_repo_map",
251 "success": true,
252 "step_count": 7,
253 "failed_steps": 0,
254 "steps": [{"output": "not copied into event"}]
255 }
256 })),
257 );
258
259 assert_eq!(event.kind, TraceEventKind::ProgramExecution);
260 assert_eq!(
261 event.details.as_ref().unwrap()["program_name"],
262 "program_repo_map"
263 );
264 assert!(event.details.as_ref().unwrap().get("steps").is_none());
265 }
266
267 fn dummy_event(i: u32) -> TraceEvent {
268 TraceEvent::tool_execution(
269 "read",
270 true,
271 0,
272 Duration::from_millis(i as u64),
273 i as usize,
274 None,
275 )
276 }
277
278 #[test]
279 fn with_max_events_caps_buffer_fifo() {
280 let sink = InMemoryTraceSink::with_max_events(3);
281 for i in 0..10 {
282 sink.record(dummy_event(i));
283 }
284 let events = sink.events();
285 assert_eq!(events.len(), 3, "buffer must be capped");
286 assert_eq!(events[0].duration_ms, 7);
289 assert_eq!(events[2].duration_ms, 9);
290 }
291
292 #[test]
293 fn default_sink_is_unbounded() {
294 let sink = InMemoryTraceSink::new();
295 for i in 0..50 {
296 sink.record(dummy_event(i));
297 }
298 assert_eq!(sink.events().len(), 50);
299 }
300
301 #[test]
302 fn replacing_events_applies_fifo_retention() {
303 let sink = InMemoryTraceSink::with_max_events(3);
304 sink.replace_events((0..10).map(dummy_event).collect());
305
306 let events = sink.events();
307 assert_eq!(events.len(), 3);
308 assert_eq!(events[0].duration_ms, 7);
309 assert_eq!(events[2].duration_ms, 9);
310 }
311
312 #[test]
313 fn zero_retention_drops_restored_and_live_events() {
314 let sink = InMemoryTraceSink::with_max_events(0);
315 sink.replace_events(vec![dummy_event(1), dummy_event(2)]);
316 sink.record(dummy_event(3));
317
318 assert!(sink.events().is_empty());
319 }
320}