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        /// Whether the run stopped having modified nothing, when its blueprint
100        /// gave it a way to. `complete` says the pipeline reached the end, not
101        /// that it achieved anything; this is the difference.
102        empty_output: bool,
103        at_ms: i64,
104    },
105    /// One per-run log line, as also written to the stage's log files.
106    Log {
107        run_id: String,
108        stage_index: usize,
109        kind: LogKind,
110        line: String,
111    },
112}
113
114impl TelemetryEvent {
115    /// A stable short name for this event's variant.
116    ///
117    /// Exists so a test can `assert_eq!(event.kind(), "run_started")` rather
118    /// than `assert!(matches!(event, ...))` - the `matches!` non-matching arm
119    /// is a region only a *failing* assertion ever reaches, which reads as
120    /// uncovered under the workspace's 100% gate. Useful in its own right for
121    /// structured logging, where the kind is the field worth indexing on.
122    #[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    /// The run this event belongs to.
137    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/// How the daemon as a whole is doing, sampled once per safety re-drive.
152///
153/// Deliberately not a [`TelemetryEvent`]: every variant of that enum belongs to
154/// one run, and this belongs to none of them. The distinction is the point. A
155/// daemon whose lanes are full and whose runs have all stopped moving emits no
156/// per-run telemetry at all, precisely because nothing is happening, so the
157/// silence that issue #191 reported was indistinguishable from an idle night.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
159pub struct LaneHealth {
160    /// Agents doing work, or ready to.
161    pub agents_active: usize,
162    /// Agents blocked on input, a child, or a prompt.
163    pub agents_waiting: usize,
164    /// Tool batches holding lane capacity and running.
165    pub tools_busy: usize,
166    /// Tool batches waiting for lane capacity.
167    pub tools_queued: usize,
168    /// Tool batches parked on an unbounded wait, holding no capacity.
169    pub tools_parked: usize,
170    /// The tool lane's concurrency cap, including any relief granted.
171    pub tools_workers: usize,
172    /// Consecutive re-drives that found a lane at capacity and no run moving.
173    pub dead_cycles: u32,
174    /// Extra tool-lane capacity handed out on this sample, if any.
175    pub relief_granted: usize,
176}
177
178/// One provider the daemon has stopped sending work to, sampled alongside
179/// [`LaneHealth`].
180///
181/// Daemon-wide for the same reason as `LaneHealth`: a provider out of credits
182/// belongs to no single run, and the runs it kills emit nothing useful because
183/// they die before doing anything (issue #201).
184///
185/// `reason` is the label rather than the runtime's own enum: this crate sits
186/// below `leviath-providers`, so the type that names it is not in scope here.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ProviderHealth {
189    /// The provider taken out of service.
190    pub provider: String,
191    /// Why, as a stable lowercase label (`credits-exhausted`, `auth-failed`).
192    pub reason: String,
193    /// Consecutive failures accumulated against it.
194    pub consecutive_failures: u32,
195    /// Seconds until it is probed again.
196    pub retry_in_secs: u64,
197}
198
199/// Where telemetry events go.
200///
201/// Implementations must tolerate being called from the engine's tick loop:
202/// `emit` should hand off or record cheaply, never block on network I/O.
203pub trait TelemetrySink: Send + Sync {
204    /// Record one event.
205    fn emit(&self, event: TelemetryEvent);
206
207    /// Record one daemon-wide health sample. Default: ignore it, so a sink that
208    /// only cares about runs needs no changes.
209    fn observe_lanes(&self, _health: LaneHealth) {}
210
211    /// Record which providers are currently out of service, sampled on the same
212    /// re-drive tick as [`TelemetrySink::observe_lanes`]. Default: ignore it,
213    /// so an existing sink keeps compiling unchanged.
214    fn observe_providers(&self, _down: &[ProviderHealth]) {}
215
216    /// Flush any buffered export before shutdown. Default: nothing buffered.
217    fn force_flush(&self) {}
218}
219
220/// The sink used when no telemetry backend is installed: drops everything.
221pub struct NoopSink;
222
223impl TelemetrySink for NoopSink {
224    fn emit(&self, _event: TelemetryEvent) {}
225}
226
227/// A sink that records every event in memory, for tests to assert on.
228#[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    /// A snapshot of everything emitted so far, in order.
238    pub fn events(&self) -> Vec<TelemetryEvent> {
239        self.events.lock().expect("telemetry event lock").clone()
240    }
241
242    /// Every lane-health sample recorded so far, in order.
243    pub fn lane_samples(&self) -> Vec<LaneHealth> {
244        self.lanes.lock().expect("telemetry lane lock").clone()
245    }
246
247    /// Every provider-health sample recorded so far, in order.
248    pub fn provider_samples(&self) -> Vec<Vec<ProviderHealth>> {
249        self.providers
250            .lock()
251            .expect("telemetry provider lock")
252            .clone()
253    }
254
255    /// How many times `force_flush` was called.
256    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        // The default trait body: a sink that only cares about runs drops it.
329        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        // The empty sample matters too: it is how a collector sees a provider
344        // come back, not just go away.
345        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        // The trait's default force_flush is a no-op; exercise it through the
367        // trait object the runtime actually holds.
368        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}