Skip to main content

leviath_core/
telemetry.rs

1//! The telemetry seam: pure-data lifecycle events and the sink they flow into.
2//!
3//! The runtime's observability system translates ECS state changes into
4//! [`TelemetryEvent`] values and hands them to whatever [`TelemetrySink`] the
5//! host installed. The events carry plain data only - no SDK types - so the
6//! runtime never depends on an exporter, and tests can assert on the exact
7//! event stream with [`MemorySink`]. The OpenTelemetry-backed sink lives in
8//! `leviath-telemetry`; a host that installs nothing gets [`NoopSink`].
9
10/// What kind of per-run log line a [`TelemetryEvent::Log`] carries.
11///
12/// Mirrors the two per-stage files the persistence layer writes: `output.log`
13/// (the model's own text) and `logs.log` (tool results, token counts, errors).
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum LogKind {
16    /// A line of assistant output (`output.log`).
17    Output,
18    /// A runtime log line - tool results, token counts, errors (`logs.log`).
19    Runtime,
20}
21
22/// One observable moment in an agent run's life.
23///
24/// Timestamps are milliseconds since the Unix epoch (`at_ms`) so a sink can
25/// reconstruct span boundaries without sub-second drift; durations are
26/// measured wall-clock milliseconds at the point the work actually ran.
27#[derive(Debug, Clone, PartialEq)]
28pub enum TelemetryEvent {
29    /// An agent run became visible to the observer.
30    RunStarted {
31        run_id: String,
32        agent_name: String,
33        /// The run-level model hint from spawn metadata, if one was recorded.
34        model: Option<String>,
35        /// Present when this run is a sub-agent of another run.
36        parent_run_id: Option<String>,
37        /// True when the run was reloaded from disk rather than freshly
38        /// spawned - its earlier life was traced (if at all) by a previous
39        /// daemon process, so this trace starts mid-run.
40        recovered: bool,
41        at_ms: i64,
42    },
43    /// The run entered a stage (including the first).
44    StageEntered {
45        run_id: String,
46        stage_index: usize,
47        stage_name: String,
48        at_ms: i64,
49    },
50    /// The run left a stage; token counts are the stage's own totals.
51    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    /// One inference call finished (successfully or not).
60    InferenceCompleted {
61        run_id: String,
62        stage_name: String,
63        provider: String,
64        model: String,
65        /// Wall-clock time of the provider call, including retries.
66        latency_ms: u64,
67        prompt_tokens: usize,
68        completion_tokens: usize,
69        cached_tokens: usize,
70        success: bool,
71    },
72    /// One tool call finished.
73    ToolCallCompleted {
74        run_id: String,
75        stage_name: String,
76        tool_name: String,
77        /// Wall-clock time of the batch the call ran in. Tool calls execute
78        /// in batches and the executor reports one duration per batch, so
79        /// every call in a batch carries the same figure.
80        batch_latency_ms: u64,
81        /// Derived from the `[error] ` result-text convention every executor
82        /// uses; a heuristic, not a structured status.
83        success: bool,
84    },
85    /// A context compaction finished.
86    CompactionCompleted {
87        run_id: String,
88        stage_name: String,
89        success: bool,
90    },
91    /// The run reached a terminal status; totals are run-wide.
92    RunCompleted {
93        run_id: String,
94        /// The terminal status label: `complete`, `error`, or `cancelled`.
95        status: String,
96        prompt_tokens: usize,
97        completion_tokens: usize,
98        tool_calls: usize,
99        at_ms: i64,
100    },
101    /// One per-run log line, as also written to the stage's log files.
102    Log {
103        run_id: String,
104        stage_index: usize,
105        kind: LogKind,
106        line: String,
107    },
108}
109
110impl TelemetryEvent {
111    /// A stable short name for this event's variant.
112    ///
113    /// Exists so a test can `assert_eq!(event.kind(), "run_started")` rather
114    /// than `assert!(matches!(event, ...))` - the `matches!` non-matching arm
115    /// is a region only a *failing* assertion ever reaches, which reads as
116    /// uncovered under the workspace's 100% gate. Useful in its own right for
117    /// structured logging, where the kind is the field worth indexing on.
118    #[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    /// The run this event belongs to.
133    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
147/// Where telemetry events go.
148///
149/// Implementations must tolerate being called from the engine's tick loop:
150/// `emit` should hand off or record cheaply, never block on network I/O.
151pub trait TelemetrySink: Send + Sync {
152    /// Record one event.
153    fn emit(&self, event: TelemetryEvent);
154
155    /// Flush any buffered export before shutdown. Default: nothing buffered.
156    fn force_flush(&self) {}
157}
158
159/// The sink used when no telemetry backend is installed: drops everything.
160pub struct NoopSink;
161
162impl TelemetrySink for NoopSink {
163    fn emit(&self, _event: TelemetryEvent) {}
164}
165
166/// A sink that records every event in memory, for tests to assert on.
167#[derive(Default)]
168pub struct MemorySink {
169    events: std::sync::Mutex<Vec<TelemetryEvent>>,
170    flushes: std::sync::atomic::AtomicUsize,
171}
172
173impl MemorySink {
174    /// A snapshot of everything emitted so far, in order.
175    pub fn events(&self) -> Vec<TelemetryEvent> {
176        self.events.lock().expect("telemetry event lock").clone()
177    }
178
179    /// How many times `force_flush` was called.
180    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        // The trait's default force_flush is a no-op; exercise it through the
244        // trait object the runtime actually holds.
245        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}