1use chrono::{DateTime, Utc};
7use kranz_engine::events::{Event, EventKind};
8use kranz_engine::types::TokenUsage;
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, PartialEq)]
14pub enum AttrValue {
15 String(String),
16 I64(i64),
17 F64(f64),
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum SpanStatus {
23 Unset,
24 Ok,
25 Error(String),
26}
27
28#[derive(Debug, Clone, PartialEq)]
33pub struct MissionSpan {
34 pub trace_id: [u8; 16],
35 pub span_id: [u8; 8],
36 pub parent_span_id: Option<[u8; 8]>,
37 pub name: String,
38 pub start: DateTime<Utc>,
39 pub end: DateTime<Utc>,
40 pub attributes: Vec<(String, AttrValue)>,
41 pub status: SpanStatus,
42}
43
44pub fn trace_id(mission_id: &str) -> [u8; 16] {
46 let digest = Sha256::digest(mission_id.as_bytes());
47 let mut out = [0u8; 16];
48 out.copy_from_slice(&digest[..16]);
49 out
50}
51
52pub fn span_id(mission_id: &str, open_seq: u64) -> [u8; 8] {
57 let digest = Sha256::digest(format!("{mission_id}:{open_seq}").as_bytes());
58 let mut out = [0u8; 8];
59 out.copy_from_slice(&digest[..8]);
60 out
61}
62
63fn tokens_attrs(tokens: &TokenUsage) -> [(String, AttrValue); 4] {
64 [
65 (
66 "kranz.tokens.input".to_string(),
67 AttrValue::I64(tokens.input as i64),
68 ),
69 (
70 "kranz.tokens.output".to_string(),
71 AttrValue::I64(tokens.output as i64),
72 ),
73 (
74 "kranz.tokens.cache_read".to_string(),
75 AttrValue::I64(tokens.cache_read as i64),
76 ),
77 (
78 "kranz.tokens.cache_write".to_string(),
79 AttrValue::I64(tokens.cache_write as i64),
80 ),
81 ]
82}
83
84struct OpenMilestone {
86 open_seq: u64,
87 start: DateTime<Utc>,
88 title: String,
89 fix_cycles: u32,
90 validating: bool,
91}
92
93struct OpenRun {
95 open_seq: u64,
96 start: DateTime<Utc>,
97 role: kranz_engine::types::Role,
98 model: String,
99 feature_id: Option<String>,
100 milestone_id: Option<String>,
101}
102
103pub const OTEL_TEXT_MAX_CHARS: usize = 512;
107
108fn clean_text(text: &str) -> String {
118 kranz_engine::scrub::truncate_chars(&kranz_engine::scrub::scrub(text), OTEL_TEXT_MAX_CHARS)
119}
120
121pub const OTEL_ID_MAX_CHARS: usize = 128;
125
126fn clean_id(id: &str) -> String {
141 kranz_engine::scrub::truncate_chars(&kranz_engine::scrub::scrub(id), OTEL_ID_MAX_CHARS)
142}
143
144pub fn map_mission(events: &[Event]) -> Vec<MissionSpan> {
154 let mut spans = Vec::new();
155
156 let mission_id = match events.first() {
157 Some(e) => e.mission_id.clone(),
158 None => return spans,
159 };
160
161 let mut goal = String::new();
162 let mut created_seq: Option<u64> = None;
163 let mut created_ts: Option<DateTime<Utc>> = None;
164
165 let mut milestone_titles: HashMap<String, String> = HashMap::new();
167 let mut open_milestones: HashMap<String, OpenMilestone> = HashMap::new();
168 let mut open_runs: HashMap<String, OpenRun> = HashMap::new();
169
170 let mut total_tokens = TokenUsage::default();
171 let mut total_cost = 0.0_f64;
172
173 for event in events {
174 match &event.kind {
175 EventKind::MissionCreated { goal: g, .. } => {
176 goal = clean_text(g);
177 created_seq = Some(event.seq);
178 created_ts = Some(event.ts);
179 }
180
181 EventKind::PlanApproved { plan, .. } => {
182 for (mi, pm) in plan.milestones.iter().enumerate() {
183 milestone_titles.insert(format!("ms-{}", mi + 1), clean_text(&pm.title));
184 }
185 }
186
187 EventKind::MilestoneStarted { milestone_id, .. } => {
188 let title = milestone_titles
189 .get(milestone_id)
190 .cloned()
191 .unwrap_or_else(|| clean_id(milestone_id));
192 open_milestones.insert(
193 milestone_id.clone(),
194 OpenMilestone {
195 open_seq: event.seq,
196 start: event.ts,
197 title,
198 fix_cycles: 0,
199 validating: false,
200 },
201 );
202 }
203
204 EventKind::MilestoneValidating { milestone_id } => {
205 if let Some(open) = open_milestones.get_mut(milestone_id) {
206 open.validating = true;
207 }
208 }
209
210 EventKind::FixFeatureCreated { milestone_id, .. } => {
211 if let Some(open) = open_milestones.get_mut(milestone_id) {
212 if open.validating {
213 open.fix_cycles += 1;
214 open.validating = false;
215 }
216 }
217 }
218
219 EventKind::WorkerSpawned {
220 run_id,
221 role,
222 feature_id,
223 milestone_id,
224 model,
225 ..
226 } => {
227 open_runs.insert(
228 run_id.clone(),
229 OpenRun {
230 open_seq: event.seq,
231 start: event.ts,
232 role: *role,
233 model: model.clone(),
234 feature_id: feature_id.clone(),
235 milestone_id: milestone_id.clone(),
236 },
237 );
238 }
239
240 EventKind::WorkerCompleted {
241 run_id,
242 result,
243 tokens,
244 cost_usd,
245 ..
246 } => {
247 total_tokens.add(tokens);
248 if let Some(c) = cost_usd {
249 total_cost += c;
250 }
251
252 if let Some(open) = open_runs.remove(run_id) {
253 let parent = if let Some(mid) = &open.milestone_id {
254 open_milestones
255 .get(mid)
256 .map(|m| span_id(&mission_id, m.open_seq))
257 .or_else(|| root_span_id(&mission_id, created_seq))
258 } else if let Some(fid) = &open.feature_id {
259 milestone_id_from_feature_id(fid)
260 .and_then(|mid| open_milestones.get(&mid))
261 .map(|m| span_id(&mission_id, m.open_seq))
262 .or_else(|| root_span_id(&mission_id, created_seq))
263 } else {
264 root_span_id(&mission_id, created_seq)
265 };
266
267 let (status, result_str) = match result {
268 kranz_engine::types::RunResult::Pass => (SpanStatus::Ok, "pass"),
269 kranz_engine::types::RunResult::Fail => {
270 (SpanStatus::Error("fail".to_string()), "fail")
271 }
272 kranz_engine::types::RunResult::Partial => {
273 (SpanStatus::Error("partial".to_string()), "partial")
274 }
275 };
276
277 let mut attrs = vec![
278 (
279 "kranz.run.id".to_string(),
280 AttrValue::String(clean_id(run_id)),
281 ),
282 (
283 "kranz.role".to_string(),
284 AttrValue::String(role_str(open.role).to_string()),
285 ),
286 (
287 "kranz.model".to_string(),
288 AttrValue::String(clean_text(&open.model)),
289 ),
290 (
291 "kranz.run.result".to_string(),
292 AttrValue::String(result_str.to_string()),
293 ),
294 ];
295 if let Some(c) = cost_usd {
296 attrs.push(("kranz.cost.usd".to_string(), AttrValue::F64(*c)));
297 }
298 attrs.extend(tokens_attrs(tokens));
299 if let Some(fid) = &open.feature_id {
300 attrs.push((
301 "kranz.feature.id".to_string(),
302 AttrValue::String(clean_id(fid)),
303 ));
304 }
305 if let Some(mid) = &open.milestone_id {
306 attrs.push((
307 "kranz.milestone.id".to_string(),
308 AttrValue::String(clean_id(mid)),
309 ));
310 }
311
312 spans.push(MissionSpan {
313 trace_id: trace_id(&mission_id),
314 span_id: span_id(&mission_id, open.open_seq),
315 parent_span_id: parent,
316 name: format!("{} {}", role_str(open.role), clean_id(run_id)),
317 start: open.start,
318 end: event.ts,
319 attributes: attrs,
320 status,
321 });
322 }
323 }
324
325 EventKind::MilestoneCompleted { milestone_id, .. } => {
326 if let Some(open) = open_milestones.remove(milestone_id) {
327 spans.push(finished_milestone_span(
328 &mission_id,
329 milestone_id,
330 &open,
331 event.ts,
332 SpanStatus::Ok,
333 created_seq,
334 ));
335 }
336 }
337
338 EventKind::MilestoneBlocked {
339 milestone_id,
340 reason,
341 ..
342 } => {
343 if let Some(open) = open_milestones.remove(milestone_id) {
344 spans.push(finished_milestone_span(
345 &mission_id,
346 milestone_id,
347 &open,
348 event.ts,
349 SpanStatus::Error(clean_text(reason)),
350 created_seq,
351 ));
352 }
353 }
354
355 EventKind::MissionCompleted {} => {
356 if let (Some(seq), Some(start)) = (created_seq, created_ts) {
357 spans.push(finished_root_span(
358 &mission_id,
359 &goal,
360 seq,
361 start,
362 event.ts,
363 SpanStatus::Ok,
364 "complete",
365 &total_tokens,
366 total_cost,
367 ));
368 }
369 }
370
371 EventKind::MissionFailed { reason } => {
372 if let (Some(seq), Some(start)) = (created_seq, created_ts) {
373 spans.push(finished_root_span(
374 &mission_id,
375 &goal,
376 seq,
377 start,
378 event.ts,
379 SpanStatus::Error(clean_text(reason)),
380 "failed",
381 &total_tokens,
382 total_cost,
383 ));
384 }
385 }
386
387 EventKind::MissionAbandoned { reason } => {
388 if let (Some(seq), Some(start)) = (created_seq, created_ts) {
389 spans.push(finished_root_span(
390 &mission_id,
391 &goal,
392 seq,
393 start,
394 event.ts,
395 SpanStatus::Error(clean_text(reason)),
396 "abandoned",
397 &total_tokens,
398 total_cost,
399 ));
400 }
401 }
402
403 _ => {}
404 }
405 }
406
407 spans
408}
409
410fn root_span_id(mission_id: &str, created_seq: Option<u64>) -> Option<[u8; 8]> {
417 created_seq.map(|seq| span_id(mission_id, seq))
418}
419
420fn role_str(role: kranz_engine::types::Role) -> &'static str {
421 use kranz_engine::types::Role::*;
422 match role {
423 Orchestrator => "orchestrator",
424 Worker => "worker",
425 ValidatorScrutiny => "validator-scrutiny",
426 ValidatorFunctional => "validator-functional",
427 }
428}
429
430fn milestone_id_from_feature_id(feature_id: &str) -> Option<String> {
432 let rest = feature_id.strip_prefix("f-")?;
433 let m = rest.split('-').next()?;
434 Some(format!("ms-{m}"))
435}
436
437fn finished_milestone_span(
438 mission_id: &str,
439 milestone_id: &str,
440 open: &OpenMilestone,
441 end: DateTime<Utc>,
442 status: SpanStatus,
443 created_seq: Option<u64>,
444) -> MissionSpan {
445 let attrs = vec![
446 (
447 "kranz.milestone.id".to_string(),
448 AttrValue::String(clean_id(milestone_id)),
449 ),
450 (
451 "kranz.milestone.title".to_string(),
452 AttrValue::String(open.title.clone()),
453 ),
454 (
455 "kranz.milestone.status".to_string(),
456 AttrValue::String(if status == SpanStatus::Ok {
457 "complete".to_string()
458 } else {
459 "blocked".to_string()
460 }),
461 ),
462 (
463 "kranz.milestone.fix_cycles".to_string(),
464 AttrValue::I64(open.fix_cycles as i64),
465 ),
466 ];
467
468 MissionSpan {
469 trace_id: trace_id(mission_id),
470 span_id: span_id(mission_id, open.open_seq),
471 parent_span_id: root_span_id(mission_id, created_seq),
472 name: format!("milestone {}: {}", clean_id(milestone_id), open.title),
473 start: open.start,
474 end,
475 attributes: attrs,
476 status,
477 }
478}
479
480#[allow(clippy::too_many_arguments)]
481fn finished_root_span(
482 mission_id: &str,
483 goal: &str,
484 created_seq: u64,
485 start: DateTime<Utc>,
486 end: DateTime<Utc>,
487 status: SpanStatus,
488 status_str: &str,
489 tokens: &TokenUsage,
490 cost: f64,
491) -> MissionSpan {
492 let mut attrs = vec![
493 (
494 "kranz.mission.id".to_string(),
495 AttrValue::String(clean_id(mission_id)),
496 ),
497 (
498 "kranz.mission.goal".to_string(),
499 AttrValue::String(goal.to_string()),
500 ),
501 (
502 "kranz.mission.status".to_string(),
503 AttrValue::String(status_str.to_string()),
504 ),
505 ("kranz.cost.usd".to_string(), AttrValue::F64(cost)),
506 ];
507 attrs.extend(tokens_attrs(tokens));
508
509 MissionSpan {
510 trace_id: trace_id(mission_id),
511 span_id: span_id(mission_id, created_seq),
512 parent_span_id: None,
513 name: format!("mission {}", clean_id(mission_id)),
514 start,
515 end,
516 attributes: attrs,
517 status,
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn ids_are_deterministic_and_idempotent() {
527 let t1 = trace_id("m-01");
528 let t2 = trace_id("m-01");
529 assert_eq!(
530 t1, t2,
531 "trace_id must be idempotent for the same mission_id"
532 );
533
534 let expected_trace: [u8; 16] = {
537 let digest = Sha256::digest(b"m-01");
538 digest[..16].try_into().unwrap()
539 };
540 assert_eq!(t1, expected_trace);
541
542 let expected_trace_hex: [u8; 16] = [
546 0x71, 0x67, 0x71, 0x72, 0xfe, 0x96, 0x30, 0xd2, 0x52, 0x71, 0xb7, 0x23, 0x37, 0xb5,
547 0xca, 0xa4,
548 ];
549 assert_eq!(t1, expected_trace_hex);
550
551 let s1 = span_id("m-01", 42);
552 let s2 = span_id("m-01", 42);
553 assert_eq!(
554 s1, s2,
555 "span_id must be idempotent for the same (mission_id, seq)"
556 );
557
558 let expected_span: [u8; 8] = {
561 let digest = Sha256::digest(b"m-01:42");
562 digest[..8].try_into().unwrap()
563 };
564 assert_eq!(s1, expected_span);
565
566 let t_other = trace_id("m-02");
567 assert_ne!(t1, t_other, "distinct missions must get distinct trace ids");
568
569 let s_other_seq = span_id("m-01", 43);
570 assert_ne!(s1, s_other_seq, "distinct seqs must get distinct span ids");
571
572 let s_other_mission = span_id("m-02", 42);
573 assert_ne!(
574 s1, s_other_mission,
575 "distinct missions must get distinct span ids even with the same seq"
576 );
577 }
578
579 use chrono::TimeZone;
580 use kranz_engine::types::{MissionConfig, Role, RunResult, TokenUsage};
581
582 fn ts(secs: i64) -> DateTime<Utc> {
583 Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap()
584 }
585
586 fn ev(seq: u64, secs: i64, kind: EventKind) -> Event {
587 Event {
588 seq,
589 ts: ts(secs),
590 mission_id: "m-01".to_string(),
591 kind,
592 }
593 }
594
595 fn created(seq: u64, secs: i64) -> Event {
596 ev(
597 seq,
598 secs,
599 EventKind::MissionCreated {
600 goal: "ship the thing".to_string(),
601 base_branch: "main".to_string(),
602 mission_branch: "kranz/mission-m-01".to_string(),
603 config: MissionConfig::default(),
604 },
605 )
606 }
607
608 fn spawned(
609 seq: u64,
610 secs: i64,
611 run_id: &str,
612 role: Role,
613 feature_id: Option<&str>,
614 milestone_id: Option<&str>,
615 model: &str,
616 ) -> Event {
617 ev(
618 seq,
619 secs,
620 EventKind::WorkerSpawned {
621 backend: None,
622 run_id: run_id.to_string(),
623 role,
624 feature_id: feature_id.map(|s| s.to_string()),
625 milestone_id: milestone_id.map(|s| s.to_string()),
626 candidate: None,
627 executor_route: None,
628 sdk_session_id: "sdk-1".to_string(),
629 model: model.to_string(),
630 quant: "n/a".to_string(),
631 weight_hash: None,
632 prompt_hash: "hash".to_string(),
633 transcript_path: "path".to_string(),
634 },
635 )
636 }
637
638 fn completed(
639 seq: u64,
640 secs: i64,
641 run_id: &str,
642 result: RunResult,
643 tokens: TokenUsage,
644 cost_usd: Option<f64>,
645 ) -> Event {
646 ev(
647 seq,
648 secs,
649 EventKind::WorkerCompleted {
650 run_id: run_id.to_string(),
651 result,
652 tokens,
653 cost_usd,
654 report: None,
655 },
656 )
657 }
658
659 fn milestone_started(seq: u64, secs: i64, milestone_id: &str) -> Event {
660 ev(
661 seq,
662 secs,
663 EventKind::MilestoneStarted {
664 milestone_id: milestone_id.to_string(),
665 start_sha: "abc123".to_string(),
666 },
667 )
668 }
669
670 fn milestone_completed(seq: u64, secs: i64, milestone_id: &str) -> Event {
671 ev(
672 seq,
673 secs,
674 EventKind::MilestoneCompleted {
675 milestone_id: milestone_id.to_string(),
676 tag: None,
677 },
678 )
679 }
680
681 fn sample_tokens() -> TokenUsage {
682 TokenUsage {
683 input: 100,
684 output: 50,
685 cache_read: 10,
686 cache_write: 5,
687 }
688 }
689
690 #[test]
691 fn mission_maps_to_trace_root() {
692 let events = vec![
693 created(1, 0),
694 milestone_started(2, 10, "ms-1"),
695 spawned(3, 20, "run-1", Role::Worker, Some("f-1-1"), None, "sonnet"),
696 completed(4, 30, "run-1", RunResult::Pass, sample_tokens(), Some(0.5)),
697 milestone_completed(5, 40, "ms-1"),
698 ev(6, 50, EventKind::MissionCompleted {}),
699 ];
700
701 let spans = map_mission(&events);
702 let roots: Vec<_> = spans
703 .iter()
704 .filter(|s| s.parent_span_id.is_none())
705 .collect();
706 assert_eq!(
707 roots.len(),
708 1,
709 "expected exactly one root span, got: {spans:#?}"
710 );
711
712 let root = roots[0];
713 assert_eq!(root.trace_id, trace_id("m-01"));
714 assert_eq!(root.start, ts(0));
715 assert_eq!(root.end, ts(50));
716 assert_eq!(root.status, SpanStatus::Ok);
717 }
718
719 #[test]
720 fn mission_without_terminal_event_emits_no_root() {
721 let events = vec![created(1, 0), milestone_started(2, 10, "ms-1")];
722 let spans = map_mission(&events);
723 assert!(
724 spans.iter().all(|s| s.parent_span_id.is_some()),
725 "no root span should be emitted for a still-running mission"
726 );
727 }
728
729 #[test]
730 fn run_span_carries_cost_and_token_attributes() {
731 let events = vec![
732 created(1, 0),
733 spawned(2, 10, "run-1", Role::Worker, None, None, "sonnet"),
734 completed(3, 20, "run-1", RunResult::Pass, sample_tokens(), Some(1.25)),
735 ];
736
737 let spans = map_mission(&events);
738 let run_span = spans
739 .iter()
740 .find(|s| s.name.contains("run-1"))
741 .expect("run span expected");
742
743 assert_eq!(
744 run_span
745 .attributes
746 .iter()
747 .find(|(k, _)| k == "kranz.cost.usd")
748 .map(|(_, v)| v.clone()),
749 Some(AttrValue::F64(1.25))
750 );
751 assert_eq!(
752 run_span
753 .attributes
754 .iter()
755 .find(|(k, _)| k == "kranz.tokens.input")
756 .map(|(_, v)| v.clone()),
757 Some(AttrValue::I64(100))
758 );
759 assert_eq!(
760 run_span
761 .attributes
762 .iter()
763 .find(|(k, _)| k == "kranz.tokens.output")
764 .map(|(_, v)| v.clone()),
765 Some(AttrValue::I64(50))
766 );
767 assert_eq!(
768 run_span
769 .attributes
770 .iter()
771 .find(|(k, _)| k == "kranz.tokens.cache_read")
772 .map(|(_, v)| v.clone()),
773 Some(AttrValue::I64(10))
774 );
775 assert_eq!(
776 run_span
777 .attributes
778 .iter()
779 .find(|(k, _)| k == "kranz.tokens.cache_write")
780 .map(|(_, v)| v.clone()),
781 Some(AttrValue::I64(5))
782 );
783 assert_eq!(
784 run_span
785 .attributes
786 .iter()
787 .find(|(k, _)| k == "kranz.role")
788 .map(|(_, v)| v.clone()),
789 Some(AttrValue::String("worker".to_string()))
790 );
791 assert_eq!(
792 run_span
793 .attributes
794 .iter()
795 .find(|(k, _)| k == "kranz.model")
796 .map(|(_, v)| v.clone()),
797 Some(AttrValue::String("sonnet".to_string()))
798 );
799 }
800
801 #[test]
802 fn terminal_status_maps_to_span_status() {
803 for (result, expected) in [
805 (RunResult::Pass, SpanStatus::Ok),
806 (RunResult::Fail, SpanStatus::Error("fail".to_string())),
807 (RunResult::Partial, SpanStatus::Error("partial".to_string())),
808 ] {
809 let events = vec![
810 created(1, 0),
811 spawned(2, 10, "run-1", Role::Worker, None, None, "sonnet"),
812 completed(3, 20, "run-1", result, sample_tokens(), None),
813 ];
814 let spans = map_mission(&events);
815 let run_span = spans.iter().find(|s| s.name.contains("run-1")).unwrap();
816 assert_eq!(
817 run_span.status, expected,
818 "result {result:?} should map to {expected:?}"
819 );
820 }
821
822 let complete = vec![created(1, 0), ev(2, 10, EventKind::MissionCompleted {})];
824 let root = map_mission(&complete)
825 .into_iter()
826 .find(|s| s.parent_span_id.is_none())
827 .unwrap();
828 assert_eq!(root.status, SpanStatus::Ok);
829
830 let failed = vec![
831 created(1, 0),
832 ev(
833 2,
834 10,
835 EventKind::MissionFailed {
836 reason: "boom".to_string(),
837 },
838 ),
839 ];
840 let root = map_mission(&failed)
841 .into_iter()
842 .find(|s| s.parent_span_id.is_none())
843 .unwrap();
844 assert_eq!(root.status, SpanStatus::Error("boom".to_string()));
845
846 let abandoned = vec![
847 created(1, 0),
848 ev(
849 2,
850 10,
851 EventKind::MissionAbandoned {
852 reason: "retired".to_string(),
853 },
854 ),
855 ];
856 let root = map_mission(&abandoned)
857 .into_iter()
858 .find(|s| s.parent_span_id.is_none())
859 .unwrap();
860 assert_eq!(root.status, SpanStatus::Error("retired".to_string()));
861
862 let ms_complete = vec![
864 created(1, 0),
865 milestone_started(2, 10, "ms-1"),
866 milestone_completed(3, 20, "ms-1"),
867 ];
868 let ms_span = map_mission(&ms_complete)
869 .into_iter()
870 .find(|s| s.name.contains("ms-1"))
871 .unwrap();
872 assert_eq!(ms_span.status, SpanStatus::Ok);
873
874 let ms_blocked = vec![
875 created(1, 0),
876 milestone_started(2, 10, "ms-1"),
877 ev(
878 3,
879 20,
880 EventKind::MilestoneBlocked {
881 block_context: None,
882 milestone_id: "ms-1".to_string(),
883 reason: "too many fix cycles".to_string(),
884 },
885 ),
886 ];
887 let ms_span = map_mission(&ms_blocked)
888 .into_iter()
889 .find(|s| s.name.contains("ms-1"))
890 .unwrap();
891 assert_eq!(
892 ms_span.status,
893 SpanStatus::Error("too many fix cycles".to_string())
894 );
895 }
896
897 #[test]
898 fn spans_parent_correctly() {
899 let events = vec![
900 created(1, 0),
901 spawned(2, 5, "orch-1", Role::Orchestrator, None, None, "opus"),
903 completed(3, 6, "orch-1", RunResult::Pass, sample_tokens(), None),
904 milestone_started(4, 10, "ms-1"),
906 spawned(
907 5,
908 15,
909 "val-1",
910 Role::ValidatorFunctional,
911 None,
912 Some("ms-1"),
913 "sonnet",
914 ),
915 completed(6, 16, "val-1", RunResult::Pass, sample_tokens(), None),
916 milestone_started(7, 20, "ms-2"),
918 spawned(
919 8,
920 25,
921 "run-2-1",
922 Role::Worker,
923 Some("f-2-1"),
924 None,
925 "sonnet",
926 ),
927 completed(9, 26, "run-2-1", RunResult::Pass, sample_tokens(), None),
928 milestone_completed(10, 30, "ms-1"),
929 milestone_completed(11, 31, "ms-2"),
930 ev(12, 40, EventKind::MissionCompleted {}),
931 ];
932
933 let spans = map_mission(&events);
934 let root = spans
935 .iter()
936 .find(|s| s.parent_span_id.is_none())
937 .expect("root span");
938 let ms1 = spans
939 .iter()
940 .find(|s| s.name.contains("ms-1"))
941 .expect("ms-1 span");
942 let ms2 = spans
943 .iter()
944 .find(|s| s.name.contains("ms-2"))
945 .expect("ms-2 span");
946 let orch = spans
947 .iter()
948 .find(|s| s.name.contains("orch-1"))
949 .expect("orch span");
950 let val = spans
951 .iter()
952 .find(|s| s.name.contains("val-1"))
953 .expect("val span");
954 let run2 = spans
955 .iter()
956 .find(|s| s.name.contains("run-2-1"))
957 .expect("run-2-1 span");
958
959 assert_eq!(
960 orch.parent_span_id,
961 Some(root.span_id),
962 "orchestrator run parents to root"
963 );
964 assert_eq!(
965 ms1.parent_span_id,
966 Some(root.span_id),
967 "milestone parents to root"
968 );
969 assert_eq!(
970 ms2.parent_span_id,
971 Some(root.span_id),
972 "milestone parents to root"
973 );
974 assert_eq!(
975 val.parent_span_id,
976 Some(ms1.span_id),
977 "validator run with milestoneId parents to that milestone"
978 );
979 assert_eq!(
980 run2.parent_span_id,
981 Some(ms2.span_id),
982 "run with featureId f-2-1 parents to ms-2"
983 );
984 }
985
986 #[test]
987 fn replay_uses_event_timestamps() {
988 let events = vec![
989 created(1, 1000),
990 milestone_started(2, 1010, "ms-1"),
991 spawned(
992 3,
993 1020,
994 "run-1",
995 Role::Worker,
996 Some("f-1-1"),
997 None,
998 "sonnet",
999 ),
1000 completed(
1001 4,
1002 1030,
1003 "run-1",
1004 RunResult::Pass,
1005 sample_tokens(),
1006 Some(0.1),
1007 ),
1008 milestone_completed(5, 1040, "ms-1"),
1009 ev(6, 1050, EventKind::MissionCompleted {}),
1010 ];
1011
1012 let spans = map_mission(&events);
1013 assert_eq!(spans.len(), 3, "expected root, milestone, and run spans");
1014
1015 let root = spans.iter().find(|s| s.parent_span_id.is_none()).unwrap();
1016 assert_eq!(root.start, ts(1000));
1017 assert_eq!(root.end, ts(1050));
1018
1019 let ms = spans.iter().find(|s| s.name.contains("ms-1")).unwrap();
1020 assert_eq!(ms.start, ts(1010));
1021 assert_eq!(ms.end, ts(1040));
1022
1023 let run = spans.iter().find(|s| s.name.contains("run-1")).unwrap();
1024 assert_eq!(run.start, ts(1020));
1025 assert_eq!(run.end, ts(1030));
1026 }
1027
1028 #[test]
1034 fn otel_mission_text_is_scrubbed_and_bounded() {
1035 let secret = "sk-ant-F00barBazQuux9_7";
1036 let long = "g".repeat(OTEL_TEXT_MAX_CHARS + 500);
1037 let events = vec![
1038 ev(
1039 1,
1040 0,
1041 EventKind::MissionCreated {
1042 goal: format!("{}={secret} {long}", "api_key"),
1043 base_branch: "main".to_string(),
1044 mission_branch: "kranz/mission-m-01".to_string(),
1045 config: MissionConfig::default(),
1046 },
1047 ),
1048 ev(
1049 2,
1050 10,
1051 EventKind::MissionFailed {
1052 reason: format!("failed with {}={secret}", "api_key"),
1053 },
1054 ),
1055 ];
1056
1057 let root = map_mission(&events)
1058 .into_iter()
1059 .find(|s| s.parent_span_id.is_none())
1060 .unwrap();
1061 let goal = root
1062 .attributes
1063 .iter()
1064 .find(|(key, _)| key == "kranz.mission.goal")
1065 .map(|(_, value)| value.clone())
1066 .unwrap();
1067 let AttrValue::String(goal) = goal else {
1068 panic!("goal is a string attribute");
1069 };
1070 assert!(!goal.contains(secret), "goal leaked a secret: {goal}");
1071 assert!(goal.contains("[REDACTED]"));
1072 assert!(
1073 goal.chars().count() <= OTEL_TEXT_MAX_CHARS + 32,
1074 "goal is unbounded: {} chars",
1075 goal.chars().count()
1076 );
1077 match &root.status {
1078 SpanStatus::Error(reason) => {
1079 assert!(
1080 !reason.contains(secret),
1081 "reason leaked the fixture value: {reason}"
1082 );
1083 assert!(reason.contains("[REDACTED]"));
1084 }
1085 other => panic!("expected an error status, got {other:?}"),
1086 }
1087 }
1088
1089 #[test]
1095 fn otel_run_ids_model_and_mission_id_are_scrubbed_and_bounded() {
1096 let secret = "sk-ant-F00barBazQuux9_7";
1097 let long = "z".repeat(OTEL_ID_MAX_CHARS + 200);
1098 let run_id = format!("run-{secret}-{long}");
1099 let events = vec![
1100 created(1, 0),
1101 spawned(
1102 2,
1103 5,
1104 &run_id,
1105 Role::Worker,
1106 Some(&format!("f-1-1-{secret}")),
1107 Some(&format!("ms-1-{secret}")),
1108 &format!("claude-{secret}"),
1109 ),
1110 completed(3, 9, &run_id, RunResult::Pass, TokenUsage::default(), None),
1111 ];
1112
1113 let run = map_mission(&events)
1114 .into_iter()
1115 .find(|s| s.name.starts_with("worker"))
1116 .expect("a finished run span");
1117
1118 assert!(
1119 !run.name.contains(secret),
1120 "span name leaked a secret: {}",
1121 run.name
1122 );
1123 assert!(
1124 run.name.chars().count() <= OTEL_ID_MAX_CHARS + 32,
1125 "span name is unbounded: {} chars",
1126 run.name.chars().count()
1127 );
1128
1129 let attr = |key: &str| {
1130 run.attributes
1131 .iter()
1132 .find(|(k, _)| k == key)
1133 .map(|(_, v)| match v {
1134 AttrValue::String(s) => s.clone(),
1135 other => panic!("{key} is not a string attribute: {other:?}"),
1136 })
1137 .unwrap_or_else(|| panic!("missing attribute {key}"))
1138 };
1139 for key in [
1140 "kranz.run.id",
1141 "kranz.model",
1142 "kranz.feature.id",
1143 "kranz.milestone.id",
1144 ] {
1145 let value = attr(key);
1146 assert!(!value.contains(secret), "{key} leaked a secret: {value}");
1147 assert!(
1148 value.contains("[REDACTED]"),
1149 "{key} was not scrubbed: {value}"
1150 );
1151 }
1152 let id = attr("kranz.run.id");
1153 assert!(
1154 id.chars().count() <= OTEL_ID_MAX_CHARS,
1155 "kranz.run.id is unbounded: {} chars",
1156 id.chars().count()
1157 );
1158
1159 let secret_mission = format!("m-{secret}");
1161 let mut events = vec![created(1, 0), ev(2, 10, EventKind::MissionCompleted {})];
1162 for e in &mut events {
1163 e.mission_id = secret_mission.clone();
1164 }
1165 let root = map_mission(&events)
1166 .into_iter()
1167 .find(|s| s.parent_span_id.is_none())
1168 .expect("a root span");
1169 assert!(
1170 !root.name.contains(secret),
1171 "root name leaked: {}",
1172 root.name
1173 );
1174 let mission_attr = root
1175 .attributes
1176 .iter()
1177 .find(|(k, _)| k == "kranz.mission.id")
1178 .map(|(_, v)| format!("{v:?}"))
1179 .unwrap();
1180 assert!(
1181 !mission_attr.contains(secret),
1182 "kranz.mission.id leaked: {mission_attr}"
1183 );
1184 }
1185
1186 #[test]
1189 fn otel_milestone_title_is_scrubbed() {
1190 let secret = "sk-ant-F00barBazQuux9_7";
1191 let plan = kranz_engine::types::Plan {
1192 goal: "ship".to_string(),
1193 validation_contract: vec![],
1194 milestones: vec![kranz_engine::types::PlanMilestone {
1195 title: format!("do it with {}={secret}", "api_key"),
1196 features: vec![],
1197 }],
1198 considered_alternatives: None,
1199 command_grants: vec![],
1200 touch_set: vec![],
1201 standards_manifest: None,
1202 reviewer_independence: None,
1203 };
1204 let events = vec![
1205 created(1, 0),
1206 ev(
1207 2,
1208 5,
1209 EventKind::PlanApproved {
1210 plan,
1211 base_sha: None,
1212 },
1213 ),
1214 milestone_started(3, 10, "ms-1"),
1215 milestone_completed(4, 20, "ms-1"),
1216 ];
1217
1218 let ms = map_mission(&events)
1219 .into_iter()
1220 .find(|s| s.name.contains("ms-1"))
1221 .unwrap();
1222 assert!(!ms.name.contains(secret), "span name leaked: {}", ms.name);
1223 let title = ms
1224 .attributes
1225 .iter()
1226 .find(|(key, _)| key == "kranz.milestone.title")
1227 .map(|(_, value)| value.clone())
1228 .unwrap();
1229 assert_eq!(
1230 title,
1231 AttrValue::String(kranz_engine::scrub::scrub(&format!(
1232 "do it with {}={secret}",
1233 "api_key"
1234 )))
1235 );
1236 }
1237}