1use 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
19pub const POLL_INTERVAL: Duration = Duration::from_millis(400);
21
22struct MissionCursor {
24 events: Vec<Event>,
29 last_seq: u64,
30 exported: HashSet<[u8; 8]>,
31}
32
33pub 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
72async 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 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 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 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 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 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 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}