Skip to main content

kranz_cli/otel/
run.rs

1//! `kranz otel` runtime loop: poll mission event logs, fold spans, export.
2//!
3//! Mirrors [`crate::tail::tail_events`] and `kranz_slack::outbound_engine::run_bridge`
4//! — a read-only poll over `events.jsonl`, self-healing on read errors (log
5//! and retry next tick, never wedge the loop). No engine changes: everything
6//! here consumes `kranz_engine::event_log`/`paths` read-side APIs only.
7
8use super::emit::build_exporter;
9use super::map::{map_mission, MissionSpan};
10use anyhow::Result;
11use kranz_engine::event_log::EventLog;
12use kranz_engine::events::Event;
13use kranz_engine::paths::MissionPaths;
14use opentelemetry_otlp::SpanExporter;
15use std::collections::{HashMap, HashSet};
16use std::path::PathBuf;
17use std::time::Duration;
18
19/// Poll interval, matching [`crate::tail::POLL_INTERVAL`] and the Slack bridge.
20pub const POLL_INTERVAL: Duration = Duration::from_millis(400);
21
22/// Per-mission tailing cursor.
23struct MissionCursor {
24    /// Events accumulated since this mission was first seen (live mode:
25    /// starts empty at first sighting's head seq; --from-start: the whole
26    /// log). Kept around because [`map_mission`] needs a span's opening
27    /// event as well as its closing one to build it.
28    events: Vec<Event>,
29    last_seq: u64,
30    exported: HashSet<[u8; 8]>,
31}
32
33/// Run the `kranz otel` sidecar until Ctrl-C. Returns the process exit code
34/// (always 0 — Ctrl-C is a normal stop, not a failure).
35pub async fn run_otel(
36    repo: PathBuf,
37    mission_scope: Option<String>,
38    endpoint: String,
39    from_start: bool,
40) -> Result<i32> {
41    let scope_desc = mission_scope.as_deref().unwrap_or("all missions");
42    eprintln!("kranz otel: exporting spans to {endpoint} ({scope_desc}, from-start={from_start})");
43
44    let exporter = build_exporter(&endpoint)?;
45    let mut cursors: HashMap<String, MissionCursor> = HashMap::new();
46    let mut ticker = tokio::time::interval(POLL_INTERVAL);
47    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
48
49    loop {
50        tokio::select! {
51            _ = tokio::signal::ctrl_c() => {
52                eprintln!("kranz otel: shutting down");
53                return Ok(0);
54            }
55            _ = ticker.tick() => {
56                let missions = match &mission_scope {
57                    Some(id) => vec![id.clone()],
58                    None => MissionPaths::list_missions(&repo),
59                };
60                for mission_id in missions {
61                    if let Err(error) =
62                        poll_mission(&repo, &mission_id, from_start, &exporter, &mut cursors).await
63                    {
64                        tracing::warn!(mission = %mission_id, %error, "kranz otel: poll failed, retrying next tick");
65                    }
66                }
67            }
68        }
69    }
70}
71
72/// Advance one mission's cursor, exporting any span whose closing event was
73/// newly observed. Self-healing: read errors are surfaced to the caller,
74/// which logs and retries next tick without touching the cursor.
75async fn poll_mission(
76    repo: &std::path::Path,
77    mission_id: &str,
78    from_start: bool,
79    exporter: &SpanExporter,
80    cursors: &mut HashMap<String, MissionCursor>,
81) -> Result<()> {
82    let paths = MissionPaths::new(repo, mission_id);
83    let events_path = paths.events_file();
84    if !events_path.is_file() {
85        return Ok(());
86    }
87
88    if !cursors.contains_key(mission_id) {
89        if from_start {
90            let events = EventLog::read_events(&events_path)?;
91            let last_seq = events.last().map(|e| e.seq).unwrap_or(0);
92            let spans = map_mission(&events);
93            let exported: HashSet<[u8; 8]> = spans.iter().map(|s| s.span_id).collect();
94            super::emit::export_spans(exporter, spans).await;
95            cursors.insert(
96                mission_id.to_string(),
97                MissionCursor {
98                    events,
99                    last_seq,
100                    exported,
101                },
102            );
103        } else {
104            // First sighting in live mode: seed the cursor at the current
105            // head without replaying — only spans whose opening AND closing
106            // events arrive during the tail get built and exported.
107            let events = EventLog::read_events(&events_path)?;
108            let last_seq = events.last().map(|e| e.seq).unwrap_or(0);
109            cursors.insert(
110                mission_id.to_string(),
111                MissionCursor {
112                    events: Vec::new(),
113                    last_seq,
114                    exported: HashSet::new(),
115                },
116            );
117        }
118        return Ok(());
119    }
120
121    let cursor = cursors
122        .get_mut(mission_id)
123        .expect("just checked contains_key");
124    let new_events = EventLog::read_events_after(&events_path, cursor.last_seq)?;
125    if new_events.is_empty() {
126        return Ok(());
127    }
128
129    cursor.last_seq = new_events.last().map(|e| e.seq).unwrap_or(cursor.last_seq);
130    cursor.events.extend(new_events);
131
132    let spans: Vec<MissionSpan> = map_mission(&cursor.events)
133        .into_iter()
134        .filter(|s| !cursor.exported.contains(&s.span_id))
135        .collect();
136    for span in &spans {
137        cursor.exported.insert(span.span_id);
138    }
139    super::emit::export_spans(exporter, spans).await;
140    Ok(())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use chrono::{TimeZone, Utc};
147    use kranz_engine::events::EventKind;
148    use kranz_engine::paths::MissionPaths;
149    use kranz_engine::types::MissionConfig;
150    use std::io::Write;
151
152    fn ts(secs: i64) -> chrono::DateTime<Utc> {
153        Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap()
154    }
155
156    /// Append one hand-built event line to a raw events.jsonl (no engine
157    /// lock — these tests only ever read the log, mirroring
158    /// `EventLog::read_events`/`read_events_after`).
159    fn append(events_path: &std::path::Path, seq: u64, secs: i64, kind: EventKind) {
160        let event = Event {
161            seq,
162            ts: ts(secs),
163            mission_id: "m-01".to_string(),
164            kind,
165        };
166        let mut line = serde_json::to_string(&event).unwrap();
167        line.push('\n');
168        let mut file = std::fs::OpenOptions::new()
169            .create(true)
170            .append(true)
171            .open(events_path)
172            .unwrap();
173        file.write_all(line.as_bytes()).unwrap();
174    }
175
176    fn created_kind() -> EventKind {
177        EventKind::MissionCreated {
178            goal: "ship it".to_string(),
179            base_branch: "main".to_string(),
180            mission_branch: "kranz/mission-m-01".to_string(),
181            config: MissionConfig::default(),
182        }
183    }
184
185    #[tokio::test]
186    async fn live_mode_first_sighting_seeds_cursor_at_head_without_exporting() {
187        let tmp = tempfile::tempdir().unwrap();
188        let paths = MissionPaths::new(tmp.path(), "m-01");
189        std::fs::create_dir_all(paths.mission_dir()).unwrap();
190        let events_path = paths.events_file();
191
192        append(&events_path, 1, 0, created_kind());
193        append(&events_path, 2, 10, EventKind::MissionCompleted {});
194
195        let exporter = build_exporter("http://127.0.0.1:1/v1/traces").unwrap();
196        let mut cursors: HashMap<String, MissionCursor> = HashMap::new();
197
198        poll_mission(tmp.path(), "m-01", false, &exporter, &mut cursors)
199            .await
200            .unwrap();
201
202        let cursor = cursors.get("m-01").unwrap();
203        assert_eq!(
204            cursor.last_seq, 2,
205            "cursor should seed at the current head seq"
206        );
207        assert!(
208            cursor.events.is_empty(),
209            "live mode must not replay historic events"
210        );
211        assert!(
212            cursor.exported.is_empty(),
213            "nothing should be exported on first sighting in live mode"
214        );
215    }
216
217    #[tokio::test]
218    async fn live_mode_exports_spans_whose_open_and_close_both_arrive_during_tail() {
219        let tmp = tempfile::tempdir().unwrap();
220        let paths = MissionPaths::new(tmp.path(), "m-01");
221        std::fs::create_dir_all(paths.mission_dir()).unwrap();
222        let events_path = paths.events_file();
223
224        append(&events_path, 1, 0, created_kind());
225
226        let exporter = build_exporter("http://127.0.0.1:1/v1/traces").unwrap();
227        let mut cursors: HashMap<String, MissionCursor> = HashMap::new();
228
229        // First sighting: seeds at seq 1, nothing to replay.
230        poll_mission(tmp.path(), "m-01", false, &exporter, &mut cursors)
231            .await
232            .unwrap();
233        assert!(cursors.get("m-01").unwrap().exported.is_empty());
234
235        // Both the opening (already seeded away) and the closing event for
236        // the mission root arrive: since mission.created is before the seed
237        // point, the root span is NOT built in live mode (its open event
238        // never entered `cursor.events`).
239        append(&events_path, 2, 10, EventKind::MissionCompleted {});
240        poll_mission(tmp.path(), "m-01", false, &exporter, &mut cursors)
241            .await
242            .unwrap();
243        assert!(
244            cursors.get("m-01").unwrap().exported.is_empty(),
245            "root span's opening event predates the tail, so it must not be exported"
246        );
247
248        // A milestone whose open AND close both arrive during the tail IS
249        // exported.
250        append(
251            &events_path,
252            3,
253            20,
254            EventKind::MilestoneStarted {
255                milestone_id: "ms-1".to_string(),
256                start_sha: "abc123".to_string(),
257            },
258        );
259        append(
260            &events_path,
261            4,
262            30,
263            EventKind::MilestoneCompleted {
264                milestone_id: "ms-1".to_string(),
265                tag: None,
266            },
267        );
268        poll_mission(tmp.path(), "m-01", false, &exporter, &mut cursors)
269            .await
270            .unwrap();
271        assert_eq!(
272            cursors.get("m-01").unwrap().exported.len(),
273            1,
274            "milestone opened and closed during the tail should be exported exactly once"
275        );
276    }
277
278    #[tokio::test]
279    async fn from_start_replays_already_closed_spans_from_event_timestamps() {
280        let tmp = tempfile::tempdir().unwrap();
281        let paths = MissionPaths::new(tmp.path(), "m-01");
282        std::fs::create_dir_all(paths.mission_dir()).unwrap();
283        let events_path = paths.events_file();
284
285        append(&events_path, 1, 0, created_kind());
286        append(&events_path, 2, 10, EventKind::MissionCompleted {});
287
288        let exporter = build_exporter("http://127.0.0.1:1/v1/traces").unwrap();
289        let mut cursors: HashMap<String, MissionCursor> = HashMap::new();
290
291        poll_mission(tmp.path(), "m-01", true, &exporter, &mut cursors)
292            .await
293            .unwrap();
294
295        let cursor = cursors.get("m-01").unwrap();
296        assert_eq!(cursor.last_seq, 2);
297        assert_eq!(
298            cursor.events.len(),
299            2,
300            "--from-start replays the whole log into the cursor"
301        );
302        assert_eq!(
303            cursor.exported.len(),
304            1,
305            "the already-closed root span should be replayed and exported"
306        );
307    }
308
309    #[tokio::test]
310    async fn already_exported_span_is_not_re_exported_on_a_later_tick() {
311        let tmp = tempfile::tempdir().unwrap();
312        let paths = MissionPaths::new(tmp.path(), "m-01");
313        std::fs::create_dir_all(paths.mission_dir()).unwrap();
314        let events_path = paths.events_file();
315
316        append(&events_path, 1, 0, created_kind());
317        append(
318            &events_path,
319            2,
320            10,
321            EventKind::MilestoneStarted {
322                milestone_id: "ms-1".to_string(),
323                start_sha: "abc123".to_string(),
324            },
325        );
326        append(
327            &events_path,
328            3,
329            20,
330            EventKind::MilestoneCompleted {
331                milestone_id: "ms-1".to_string(),
332                tag: None,
333            },
334        );
335
336        let exporter = build_exporter("http://127.0.0.1:1/v1/traces").unwrap();
337        let mut cursors: HashMap<String, MissionCursor> = HashMap::new();
338        poll_mission(tmp.path(), "m-01", true, &exporter, &mut cursors)
339            .await
340            .unwrap();
341        assert_eq!(cursors.get("m-01").unwrap().exported.len(), 1);
342
343        // A later tick with no new events must not touch the cursor further.
344        poll_mission(tmp.path(), "m-01", true, &exporter, &mut cursors)
345            .await
346            .unwrap();
347        assert_eq!(
348            cursors.get("m-01").unwrap().exported.len(),
349            1,
350            "no new events, no re-export"
351        );
352    }
353}