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