1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum LogKind {
16 Output,
18 Runtime,
20}
21
22#[derive(Debug, Clone, PartialEq)]
28pub enum TelemetryEvent {
29 RunStarted {
31 run_id: String,
32 agent_name: String,
33 model: Option<String>,
35 parent_run_id: Option<String>,
37 recovered: bool,
41 at_ms: i64,
42 },
43 StageEntered {
45 run_id: String,
46 stage_index: usize,
47 stage_name: String,
48 at_ms: i64,
49 },
50 StageExited {
52 run_id: String,
53 stage_index: usize,
54 stage_name: String,
55 prompt_tokens: usize,
56 completion_tokens: usize,
57 at_ms: i64,
58 },
59 InferenceCompleted {
61 run_id: String,
62 stage_name: String,
63 provider: String,
64 model: String,
65 latency_ms: u64,
67 prompt_tokens: usize,
68 completion_tokens: usize,
69 cached_tokens: usize,
70 success: bool,
71 },
72 ToolCallCompleted {
74 run_id: String,
75 stage_name: String,
76 tool_name: String,
77 batch_latency_ms: u64,
81 success: bool,
84 },
85 CompactionCompleted {
87 run_id: String,
88 stage_name: String,
89 success: bool,
90 },
91 RunCompleted {
93 run_id: String,
94 status: String,
96 prompt_tokens: usize,
97 completion_tokens: usize,
98 tool_calls: usize,
99 at_ms: i64,
100 },
101 Log {
103 run_id: String,
104 stage_index: usize,
105 kind: LogKind,
106 line: String,
107 },
108}
109
110impl TelemetryEvent {
111 #[must_use]
119 pub fn kind(&self) -> &'static str {
120 match self {
121 Self::RunStarted { .. } => "run_started",
122 Self::StageEntered { .. } => "stage_entered",
123 Self::StageExited { .. } => "stage_exited",
124 Self::InferenceCompleted { .. } => "inference_completed",
125 Self::ToolCallCompleted { .. } => "tool_call_completed",
126 Self::CompactionCompleted { .. } => "compaction_completed",
127 Self::RunCompleted { .. } => "run_completed",
128 Self::Log { .. } => "log",
129 }
130 }
131
132 pub fn run_id(&self) -> &str {
134 match self {
135 Self::RunStarted { run_id, .. }
136 | Self::StageEntered { run_id, .. }
137 | Self::StageExited { run_id, .. }
138 | Self::InferenceCompleted { run_id, .. }
139 | Self::ToolCallCompleted { run_id, .. }
140 | Self::CompactionCompleted { run_id, .. }
141 | Self::RunCompleted { run_id, .. }
142 | Self::Log { run_id, .. } => run_id,
143 }
144 }
145}
146
147pub trait TelemetrySink: Send + Sync {
152 fn emit(&self, event: TelemetryEvent);
154
155 fn force_flush(&self) {}
157}
158
159pub struct NoopSink;
161
162impl TelemetrySink for NoopSink {
163 fn emit(&self, _event: TelemetryEvent) {}
164}
165
166#[derive(Default)]
168pub struct MemorySink {
169 events: std::sync::Mutex<Vec<TelemetryEvent>>,
170 flushes: std::sync::atomic::AtomicUsize,
171}
172
173impl MemorySink {
174 pub fn events(&self) -> Vec<TelemetryEvent> {
176 self.events.lock().expect("telemetry event lock").clone()
177 }
178
179 pub fn flush_count(&self) -> usize {
181 self.flushes.load(std::sync::atomic::Ordering::SeqCst)
182 }
183}
184
185impl TelemetrySink for MemorySink {
186 fn emit(&self, event: TelemetryEvent) {
187 self.events
188 .lock()
189 .expect("telemetry event lock")
190 .push(event);
191 }
192
193 fn force_flush(&self) {
194 self.flushes
195 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn run_started(run_id: &str) -> TelemetryEvent {
204 TelemetryEvent::RunStarted {
205 run_id: run_id.to_string(),
206 agent_name: "coder".to_string(),
207 model: Some("claude-sonnet-5".to_string()),
208 parent_run_id: None,
209 recovered: false,
210 at_ms: 1_000,
211 }
212 }
213
214 #[test]
215 fn memory_sink_records_events_in_order() {
216 let sink = MemorySink::default();
217 sink.emit(run_started("r1"));
218 sink.emit(TelemetryEvent::StageEntered {
219 run_id: "r1".to_string(),
220 stage_index: 0,
221 stage_name: "plan".to_string(),
222 at_ms: 1_001,
223 });
224 let events = sink.events();
225 assert_eq!(events.len(), 2);
226 assert_eq!(events[0].kind(), "run_started");
227 assert_eq!(events[1].kind(), "stage_entered");
228 }
229
230 #[test]
231 fn memory_sink_counts_flushes() {
232 let sink = MemorySink::default();
233 assert_eq!(sink.flush_count(), 0);
234 sink.force_flush();
235 sink.force_flush();
236 assert_eq!(sink.flush_count(), 2);
237 }
238
239 #[test]
240 fn noop_sink_accepts_events_and_default_flush() {
241 let sink = NoopSink;
242 sink.emit(run_started("r1"));
243 let boxed: Box<dyn TelemetrySink> = Box::new(NoopSink);
246 boxed.force_flush();
247 }
248
249 #[test]
250 fn run_id_reaches_every_variant() {
251 let events = [
252 run_started("r1"),
253 TelemetryEvent::StageEntered {
254 run_id: "r1".to_string(),
255 stage_index: 0,
256 stage_name: "plan".to_string(),
257 at_ms: 0,
258 },
259 TelemetryEvent::StageExited {
260 run_id: "r1".to_string(),
261 stage_index: 0,
262 stage_name: "plan".to_string(),
263 prompt_tokens: 10,
264 completion_tokens: 5,
265 at_ms: 0,
266 },
267 TelemetryEvent::InferenceCompleted {
268 run_id: "r1".to_string(),
269 stage_name: "plan".to_string(),
270 provider: "anthropic".to_string(),
271 model: "claude-sonnet-5".to_string(),
272 latency_ms: 120,
273 prompt_tokens: 10,
274 completion_tokens: 5,
275 cached_tokens: 0,
276 success: true,
277 },
278 TelemetryEvent::ToolCallCompleted {
279 run_id: "r1".to_string(),
280 stage_name: "build".to_string(),
281 tool_name: "read_file".to_string(),
282 batch_latency_ms: 8,
283 success: true,
284 },
285 TelemetryEvent::CompactionCompleted {
286 run_id: "r1".to_string(),
287 stage_name: "build".to_string(),
288 success: true,
289 },
290 TelemetryEvent::RunCompleted {
291 run_id: "r1".to_string(),
292 status: "complete".to_string(),
293 prompt_tokens: 10,
294 completion_tokens: 5,
295 tool_calls: 1,
296 at_ms: 0,
297 },
298 TelemetryEvent::Log {
299 run_id: "r1".to_string(),
300 stage_index: 0,
301 kind: LogKind::Runtime,
302 line: "[Tokens: 10 in, 5 out]".to_string(),
303 },
304 ];
305 let kinds: Vec<&str> = events.iter().map(TelemetryEvent::kind).collect();
306 assert_eq!(
307 kinds,
308 [
309 "run_started",
310 "stage_entered",
311 "stage_exited",
312 "inference_completed",
313 "tool_call_completed",
314 "compaction_completed",
315 "run_completed",
316 "log",
317 ]
318 );
319 for event in &events {
320 assert_eq!(event.run_id(), "r1");
321 }
322 }
323
324 #[test]
325 fn event_clone_debug_and_eq() {
326 let event = run_started("r1");
327 let cloned = event.clone();
328 assert_eq!(event, cloned);
329 assert!(format!("{event:?}").contains("RunStarted"));
330 assert_ne!(LogKind::Output, LogKind::Runtime);
331 assert!(format!("{:?}", LogKind::Output).contains("Output"));
332 }
333}