Skip to main content

leviath_telemetry/
log_sink.rs

1//! The `exporter = "stdout"` sink: one readable line per event through the
2//! process logger.
3//!
4//! Named "stdout" in config for familiarity with other OTEL tooling, but the
5//! lines go through `tracing` to **stderr** like every other Leviath log:
6//! stdout is `lev agent-client`'s JSON-RPC channel and must stay clean. (For
7//! the same reason this is hand-rolled rather than `opentelemetry-stdout`,
8//! which writes to stdout unconditionally.)
9
10use leviath_core::telemetry::{LaneHealth, LogKind, ProviderHealth, TelemetryEvent, TelemetrySink};
11
12/// One readable line for an event.
13pub(crate) fn format_event(event: &TelemetryEvent) -> String {
14    match event {
15        TelemetryEvent::RunStarted {
16            run_id,
17            agent_name,
18            recovered,
19            ..
20        } => {
21            let suffix = if *recovered { " (recovered)" } else { "" };
22            format!("run {run_id} started: agent {agent_name}{suffix}")
23        }
24        TelemetryEvent::StageEntered {
25            run_id,
26            stage_index,
27            stage_name,
28            ..
29        } => format!("run {run_id} entered stage {stage_index} ({stage_name})"),
30        TelemetryEvent::StageExited {
31            run_id,
32            stage_index,
33            stage_name,
34            prompt_tokens,
35            completion_tokens,
36            ..
37        } => format!(
38            "run {run_id} exited stage {stage_index} ({stage_name}): \
39             {prompt_tokens} in, {completion_tokens} out"
40        ),
41        TelemetryEvent::InferenceCompleted {
42            run_id,
43            provider,
44            model,
45            latency_ms,
46            prompt_tokens,
47            completion_tokens,
48            success,
49            ..
50        } => format!(
51            "run {run_id} inference {}: {provider}/{model} {latency_ms}ms, \
52             {prompt_tokens} in, {completion_tokens} out",
53            if *success { "ok" } else { "failed" }
54        ),
55        TelemetryEvent::ToolCallCompleted {
56            run_id,
57            tool_name,
58            batch_latency_ms,
59            success,
60            ..
61        } => format!(
62            "run {run_id} tool {tool_name} {}: batch {batch_latency_ms}ms",
63            if *success { "ok" } else { "failed" }
64        ),
65        TelemetryEvent::CompactionCompleted {
66            run_id, success, ..
67        } => format!(
68            "run {run_id} compaction {}",
69            if *success { "ok" } else { "failed" }
70        ),
71        TelemetryEvent::RunCompleted {
72            run_id,
73            status,
74            prompt_tokens,
75            completion_tokens,
76            tool_calls,
77            empty_output,
78            ..
79        } => format!(
80            "run {run_id} {status}: {prompt_tokens} in, {completion_tokens} out, \
81             {tool_calls} tool calls{}",
82            if *empty_output { " (no output)" } else { "" }
83        ),
84        TelemetryEvent::Log {
85            run_id,
86            stage_index,
87            kind,
88            line,
89        } => {
90            let kind = match kind {
91                LogKind::Output => "output",
92                LogKind::Runtime => "log",
93            };
94            format!("run {run_id} stage {stage_index} {kind}: {line}")
95        }
96    }
97}
98
99/// One readable line for a daemon-wide health sample.
100pub(crate) fn format_lane_health(health: &LaneHealth) -> String {
101    format!(
102        "lanes: agents active={} waiting={}, tools {}/{} busy, {} parked, {} queued, \
103         dead cycles {}, relief {}",
104        health.agents_active,
105        health.agents_waiting,
106        health.tools_busy,
107        health.tools_workers,
108        health.tools_parked,
109        health.tools_queued,
110        health.dead_cycles,
111        health.relief_granted,
112    )
113}
114
115/// One readable line for the providers currently out of service.
116///
117/// `None` while everything is serving, so a healthy daemon does not narrate a
118/// line saying nothing is wrong on every re-drive.
119pub(crate) fn format_providers_down(down: &[ProviderHealth]) -> Option<String> {
120    if down.is_empty() {
121        return None;
122    }
123    let each = down
124        .iter()
125        .map(|p| {
126            format!(
127                "{} ({}, {} failures, retry in {}s)",
128                p.provider, p.reason, p.consecutive_failures, p.retry_in_secs
129            )
130        })
131        .collect::<Vec<_>>()
132        .join(", ");
133    Some(format!("providers out of service: {each}"))
134}
135
136/// [`TelemetrySink`] that narrates events as `tracing` lines (→ stderr).
137pub struct LogSink;
138
139impl TelemetrySink for LogSink {
140    fn emit(&self, event: TelemetryEvent) {
141        tracing::info!(target: "leviath::telemetry", "{}", format_event(&event));
142    }
143
144    fn observe_lanes(&self, health: LaneHealth) {
145        tracing::info!(target: "leviath::telemetry", "{}", format_lane_health(&health));
146    }
147
148    fn observe_providers(&self, down: &[ProviderHealth]) {
149        if let Some(line) = format_providers_down(down) {
150            tracing::warn!(target: "leviath::telemetry", "{line}");
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn every_event_formats_to_one_line() {
161        let events = [
162            (
163                TelemetryEvent::RunStarted {
164                    run_id: "r1".to_string(),
165                    agent_name: "coder".to_string(),
166                    model: None,
167                    parent_run_id: None,
168                    recovered: false,
169                    at_ms: 0,
170                },
171                "run r1 started: agent coder",
172            ),
173            (
174                TelemetryEvent::RunStarted {
175                    run_id: "r1".to_string(),
176                    agent_name: "coder".to_string(),
177                    model: None,
178                    parent_run_id: None,
179                    recovered: true,
180                    at_ms: 0,
181                },
182                "run r1 started: agent coder (recovered)",
183            ),
184            (
185                TelemetryEvent::StageEntered {
186                    run_id: "r1".to_string(),
187                    stage_index: 1,
188                    stage_name: "build".to_string(),
189                    at_ms: 0,
190                },
191                "run r1 entered stage 1 (build)",
192            ),
193            (
194                TelemetryEvent::StageExited {
195                    run_id: "r1".to_string(),
196                    stage_index: 1,
197                    stage_name: "build".to_string(),
198                    prompt_tokens: 10,
199                    completion_tokens: 4,
200                    at_ms: 0,
201                },
202                "run r1 exited stage 1 (build): 10 in, 4 out",
203            ),
204            (
205                TelemetryEvent::InferenceCompleted {
206                    run_id: "r1".to_string(),
207                    stage_name: "build".to_string(),
208                    provider: "anthropic".to_string(),
209                    model: "m".to_string(),
210                    latency_ms: 120,
211                    prompt_tokens: 10,
212                    completion_tokens: 4,
213                    cached_tokens: 0,
214                    success: true,
215                },
216                "run r1 inference ok: anthropic/m 120ms, 10 in, 4 out",
217            ),
218            (
219                TelemetryEvent::InferenceCompleted {
220                    run_id: "r1".to_string(),
221                    stage_name: "build".to_string(),
222                    provider: "anthropic".to_string(),
223                    model: "m".to_string(),
224                    latency_ms: 120,
225                    prompt_tokens: 0,
226                    completion_tokens: 0,
227                    cached_tokens: 0,
228                    success: false,
229                },
230                "run r1 inference failed: anthropic/m 120ms, 0 in, 0 out",
231            ),
232            (
233                TelemetryEvent::ToolCallCompleted {
234                    run_id: "r1".to_string(),
235                    stage_name: "build".to_string(),
236                    tool_name: "read_file".to_string(),
237                    batch_latency_ms: 30,
238                    success: true,
239                },
240                "run r1 tool read_file ok: batch 30ms",
241            ),
242            (
243                TelemetryEvent::ToolCallCompleted {
244                    run_id: "r1".to_string(),
245                    stage_name: "build".to_string(),
246                    tool_name: "shell".to_string(),
247                    batch_latency_ms: 30,
248                    success: false,
249                },
250                "run r1 tool shell failed: batch 30ms",
251            ),
252            (
253                TelemetryEvent::CompactionCompleted {
254                    run_id: "r1".to_string(),
255                    stage_name: "build".to_string(),
256                    success: true,
257                },
258                "run r1 compaction ok",
259            ),
260            (
261                TelemetryEvent::CompactionCompleted {
262                    run_id: "r1".to_string(),
263                    stage_name: "build".to_string(),
264                    success: false,
265                },
266                "run r1 compaction failed",
267            ),
268            (
269                TelemetryEvent::RunCompleted {
270                    run_id: "r1".to_string(),
271                    status: "complete".to_string(),
272                    prompt_tokens: 10,
273                    completion_tokens: 4,
274                    tool_calls: 2,
275                    empty_output: false,
276                    at_ms: 0,
277                },
278                "run r1 complete: 10 in, 4 out, 2 tool calls",
279            ),
280            (
281                // Same tallies, but the run changed nothing: the line has to
282                // say so, or it reads as a success (issue #192).
283                TelemetryEvent::RunCompleted {
284                    run_id: "r1".to_string(),
285                    status: "complete".to_string(),
286                    prompt_tokens: 10,
287                    completion_tokens: 4,
288                    tool_calls: 2,
289                    empty_output: true,
290                    at_ms: 0,
291                },
292                "run r1 complete: 10 in, 4 out, 2 tool calls (no output)",
293            ),
294            (
295                TelemetryEvent::Log {
296                    run_id: "r1".to_string(),
297                    stage_index: 0,
298                    kind: LogKind::Output,
299                    line: "hello".to_string(),
300                },
301                "run r1 stage 0 output: hello",
302            ),
303            (
304                TelemetryEvent::Log {
305                    run_id: "r1".to_string(),
306                    stage_index: 0,
307                    kind: LogKind::Runtime,
308                    line: "[Tokens: 1 in, 1 out]".to_string(),
309                },
310                "run r1 stage 0 log: [Tokens: 1 in, 1 out]",
311            ),
312        ];
313        for (event, expected) in events {
314            assert_eq!(format_event(&event), expected);
315        }
316    }
317
318    #[test]
319    fn emit_routes_through_tracing_without_panicking() {
320        let _guard = leviath_testkit::tracing_guard();
321        LogSink.emit(TelemetryEvent::RunCompleted {
322            run_id: "r1".to_string(),
323            status: "complete".to_string(),
324            prompt_tokens: 0,
325            completion_tokens: 0,
326            tool_calls: 0,
327            empty_output: false,
328            at_ms: 0,
329        });
330    }
331
332    /// The daemon-wide line reads as one sentence, with the numbers an operator
333    /// asks for in the order they ask for them.
334    #[test]
335    fn lane_health_formats_to_one_line() {
336        let line = format_lane_health(&LaneHealth {
337            agents_active: 6,
338            agents_waiting: 2,
339            tools_busy: 8,
340            tools_queued: 12,
341            tools_parked: 3,
342            tools_workers: 8,
343            dead_cycles: 4,
344            relief_granted: 2,
345        });
346        assert_eq!(
347            line,
348            "lanes: agents active=6 waiting=2, tools 8/8 busy, 3 parked, 12 queued, \
349             dead cycles 4, relief 2"
350        );
351        assert!(!line.contains('\n'), "one line: {line}");
352    }
353
354    #[test]
355    fn observe_lanes_routes_through_tracing_without_panicking() {
356        let _guard = leviath_testkit::tracing_guard();
357        LogSink.observe_lanes(LaneHealth::default());
358    }
359
360    #[test]
361    fn providers_down_formats_to_one_line() {
362        let line = format_providers_down(&[
363            ProviderHealth {
364                provider: "openrouter".to_string(),
365                reason: "credits-exhausted".to_string(),
366                consecutive_failures: 3,
367                retry_in_secs: 240,
368            },
369            ProviderHealth {
370                provider: "anthropic".to_string(),
371                reason: "auth-failed".to_string(),
372                consecutive_failures: 5,
373                retry_in_secs: 30,
374            },
375        ])
376        .expect("something is down");
377        assert_eq!(
378            line,
379            "providers out of service: openrouter (credits-exhausted, 3 failures, retry in 240s), \
380             anthropic (auth-failed, 5 failures, retry in 30s)"
381        );
382        assert!(!line.contains('\n'), "one line: {line}");
383    }
384
385    /// A healthy daemon must not narrate "nothing is wrong" every 30 seconds.
386    #[test]
387    fn nothing_down_is_no_line_at_all() {
388        assert_eq!(format_providers_down(&[]), None);
389        let _guard = leviath_testkit::tracing_guard();
390        LogSink.observe_providers(&[]);
391    }
392
393    #[test]
394    fn observe_providers_routes_through_tracing_without_panicking() {
395        let _guard = leviath_testkit::tracing_guard();
396        LogSink.observe_providers(&[ProviderHealth {
397            provider: "openrouter".to_string(),
398            reason: "credits-exhausted".to_string(),
399            consecutive_failures: 3,
400            retry_in_secs: 240,
401        }]);
402    }
403}