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 empty_output: bool,
103 at_ms: i64,
104 },
105 Log {
107 run_id: String,
108 stage_index: usize,
109 kind: LogKind,
110 line: String,
111 },
112}
113
114impl TelemetryEvent {
115 #[must_use]
123 pub fn kind(&self) -> &'static str {
124 match self {
125 Self::RunStarted { .. } => "run_started",
126 Self::StageEntered { .. } => "stage_entered",
127 Self::StageExited { .. } => "stage_exited",
128 Self::InferenceCompleted { .. } => "inference_completed",
129 Self::ToolCallCompleted { .. } => "tool_call_completed",
130 Self::CompactionCompleted { .. } => "compaction_completed",
131 Self::RunCompleted { .. } => "run_completed",
132 Self::Log { .. } => "log",
133 }
134 }
135
136 pub fn run_id(&self) -> &str {
138 match self {
139 Self::RunStarted { run_id, .. }
140 | Self::StageEntered { run_id, .. }
141 | Self::StageExited { run_id, .. }
142 | Self::InferenceCompleted { run_id, .. }
143 | Self::ToolCallCompleted { run_id, .. }
144 | Self::CompactionCompleted { run_id, .. }
145 | Self::RunCompleted { run_id, .. }
146 | Self::Log { run_id, .. } => run_id,
147 }
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
159pub struct LaneHealth {
160 pub agents_active: usize,
162 pub agents_waiting: usize,
164 pub tools_busy: usize,
166 pub tools_queued: usize,
168 pub tools_parked: usize,
170 pub tools_workers: usize,
172 pub dead_cycles: u32,
174 pub relief_granted: usize,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ProviderHealth {
189 pub provider: String,
191 pub reason: String,
193 pub consecutive_failures: u32,
195 pub retry_in_secs: u64,
197}
198
199pub trait TelemetrySink: Send + Sync {
204 fn emit(&self, event: TelemetryEvent);
206
207 fn observe_lanes(&self, _health: LaneHealth) {}
210
211 fn observe_providers(&self, _down: &[ProviderHealth]) {}
215
216 fn force_flush(&self) {}
218}
219
220pub struct NoopSink;
222
223impl TelemetrySink for NoopSink {
224 fn emit(&self, _event: TelemetryEvent) {}
225}
226
227#[derive(Default)]
229pub struct MemorySink {
230 events: std::sync::Mutex<Vec<TelemetryEvent>>,
231 lanes: std::sync::Mutex<Vec<LaneHealth>>,
232 providers: std::sync::Mutex<Vec<Vec<ProviderHealth>>>,
233 flushes: std::sync::atomic::AtomicUsize,
234}
235
236impl MemorySink {
237 pub fn events(&self) -> Vec<TelemetryEvent> {
239 self.events.lock().expect("telemetry event lock").clone()
240 }
241
242 pub fn lane_samples(&self) -> Vec<LaneHealth> {
244 self.lanes.lock().expect("telemetry lane lock").clone()
245 }
246
247 pub fn provider_samples(&self) -> Vec<Vec<ProviderHealth>> {
249 self.providers
250 .lock()
251 .expect("telemetry provider lock")
252 .clone()
253 }
254
255 pub fn flush_count(&self) -> usize {
257 self.flushes.load(std::sync::atomic::Ordering::SeqCst)
258 }
259}
260
261impl TelemetrySink for MemorySink {
262 fn emit(&self, event: TelemetryEvent) {
263 self.events
264 .lock()
265 .expect("telemetry event lock")
266 .push(event);
267 }
268
269 fn observe_lanes(&self, health: LaneHealth) {
270 self.lanes.lock().expect("telemetry lane lock").push(health);
271 }
272
273 fn observe_providers(&self, down: &[ProviderHealth]) {
274 self.providers
275 .lock()
276 .expect("telemetry provider lock")
277 .push(down.to_vec());
278 }
279
280 fn force_flush(&self) {
281 self.flushes
282 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 fn run_started(run_id: &str) -> TelemetryEvent {
291 TelemetryEvent::RunStarted {
292 run_id: run_id.to_string(),
293 agent_name: "coder".to_string(),
294 model: Some("claude-sonnet-5".to_string()),
295 parent_run_id: None,
296 recovered: false,
297 at_ms: 1_000,
298 }
299 }
300
301 #[test]
302 fn memory_sink_records_events_in_order() {
303 let sink = MemorySink::default();
304 sink.emit(run_started("r1"));
305 sink.emit(TelemetryEvent::StageEntered {
306 run_id: "r1".to_string(),
307 stage_index: 0,
308 stage_name: "plan".to_string(),
309 at_ms: 1_001,
310 });
311 let events = sink.events();
312 assert_eq!(events.len(), 2);
313 assert_eq!(events[0].kind(), "run_started");
314 assert_eq!(events[1].kind(), "stage_entered");
315 }
316
317 #[test]
318 fn memory_sink_records_lane_samples_and_the_noop_ignores_them() {
319 let sink = MemorySink::default();
320 assert!(sink.lane_samples().is_empty());
321 sink.observe_lanes(LaneHealth {
322 dead_cycles: 3,
323 ..Default::default()
324 });
325 assert_eq!(sink.lane_samples().len(), 1);
326 assert_eq!(sink.lane_samples()[0].dead_cycles, 3);
327
328 NoopSink.observe_lanes(LaneHealth::default());
330 }
331
332 #[test]
333 fn memory_sink_records_provider_samples_and_the_noop_ignores_them() {
334 let sink = MemorySink::default();
335 assert!(sink.provider_samples().is_empty());
336 let down = ProviderHealth {
337 provider: "openrouter".to_string(),
338 reason: "credits-exhausted".to_string(),
339 consecutive_failures: 3,
340 retry_in_secs: 240,
341 };
342 sink.observe_providers(std::slice::from_ref(&down));
343 sink.observe_providers(&[]);
346 assert_eq!(sink.provider_samples().len(), 2);
347 assert_eq!(sink.provider_samples()[0], vec![down]);
348 assert!(sink.provider_samples()[1].is_empty());
349
350 NoopSink.observe_providers(&[]);
351 }
352
353 #[test]
354 fn memory_sink_counts_flushes() {
355 let sink = MemorySink::default();
356 assert_eq!(sink.flush_count(), 0);
357 sink.force_flush();
358 sink.force_flush();
359 assert_eq!(sink.flush_count(), 2);
360 }
361
362 #[test]
363 fn noop_sink_accepts_events_and_default_flush() {
364 let sink = NoopSink;
365 sink.emit(run_started("r1"));
366 let boxed: Box<dyn TelemetrySink> = Box::new(NoopSink);
369 boxed.force_flush();
370 }
371
372 #[test]
373 fn run_id_reaches_every_variant() {
374 let events = [
375 run_started("r1"),
376 TelemetryEvent::StageEntered {
377 run_id: "r1".to_string(),
378 stage_index: 0,
379 stage_name: "plan".to_string(),
380 at_ms: 0,
381 },
382 TelemetryEvent::StageExited {
383 run_id: "r1".to_string(),
384 stage_index: 0,
385 stage_name: "plan".to_string(),
386 prompt_tokens: 10,
387 completion_tokens: 5,
388 at_ms: 0,
389 },
390 TelemetryEvent::InferenceCompleted {
391 run_id: "r1".to_string(),
392 stage_name: "plan".to_string(),
393 provider: "anthropic".to_string(),
394 model: "claude-sonnet-5".to_string(),
395 latency_ms: 120,
396 prompt_tokens: 10,
397 completion_tokens: 5,
398 cached_tokens: 0,
399 success: true,
400 },
401 TelemetryEvent::ToolCallCompleted {
402 run_id: "r1".to_string(),
403 stage_name: "build".to_string(),
404 tool_name: "read_file".to_string(),
405 batch_latency_ms: 8,
406 success: true,
407 },
408 TelemetryEvent::CompactionCompleted {
409 run_id: "r1".to_string(),
410 stage_name: "build".to_string(),
411 success: true,
412 },
413 TelemetryEvent::RunCompleted {
414 run_id: "r1".to_string(),
415 status: "complete".to_string(),
416 prompt_tokens: 10,
417 completion_tokens: 5,
418 tool_calls: 1,
419 empty_output: false,
420 at_ms: 0,
421 },
422 TelemetryEvent::Log {
423 run_id: "r1".to_string(),
424 stage_index: 0,
425 kind: LogKind::Runtime,
426 line: "[Tokens: 10 in, 5 out]".to_string(),
427 },
428 ];
429 let kinds: Vec<&str> = events.iter().map(TelemetryEvent::kind).collect();
430 assert_eq!(
431 kinds,
432 [
433 "run_started",
434 "stage_entered",
435 "stage_exited",
436 "inference_completed",
437 "tool_call_completed",
438 "compaction_completed",
439 "run_completed",
440 "log",
441 ]
442 );
443 for event in &events {
444 assert_eq!(event.run_id(), "r1");
445 }
446 }
447
448 #[test]
449 fn event_clone_debug_and_eq() {
450 let event = run_started("r1");
451 let cloned = event.clone();
452 assert_eq!(event, cloned);
453 assert!(format!("{event:?}").contains("RunStarted"));
454 assert_ne!(LogKind::Output, LogKind::Runtime);
455 assert!(format!("{:?}", LogKind::Output).contains("Output"));
456 }
457}