diff --git a/lib/crates/fabro-types/src/run_event/stage.rs b/lib/crates/fabro-types/src/run_event/stage.rs index 311420e73..e115da768 100644 --- a/lib/crates/fabro-types/src/run_event/stage.rs +++ b/lib/crates/fabro-types/src/run_event/stage.rs @@ -50,10 +50,12 @@ pub struct StageCompletedProps { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StageFailedProps { - pub index: usize, + pub index: usize, #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure: Option, - pub will_retry: bool, + pub failure: Option, + pub will_retry: bool, + #[serde(default)] + pub duration_ms: u64, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-workflow/src/error.rs b/lib/crates/fabro-workflow/src/error.rs index 3adbf8da1..c4736eb13 100644 --- a/lib/crates/fabro-workflow/src/error.rs +++ b/lib/crates/fabro-workflow/src/error.rs @@ -1700,11 +1700,12 @@ mod tests { // 3. Outcome → StageFailed event let failure = outcome.failure.clone().unwrap(); let event = Event::StageFailed { - node_id: "code".into(), - name: "code".into(), - index: 0, - failure: failure.clone(), - will_retry: false, + node_id: "code".into(), + name: "code".into(), + index: 0, + failure: failure.clone(), + will_retry: false, + duration_ms: 0, }; // 4. Verify classification survived all the way through diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 606211607..a11bff16c 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -181,11 +181,12 @@ pub enum Event { max_attempts: usize, }, StageFailed { - node_id: String, - name: String, - index: usize, - failure: FailureDetail, - will_retry: bool, + node_id: String, + name: String, + index: usize, + failure: FailureDetail, + will_retry: bool, + duration_ms: u64, }, StageRetrying { node_id: String, @@ -1652,11 +1653,13 @@ fn event_body_from_event(event: &Event) -> EventBody { index, failure, will_retry, + duration_ms, .. } => EventBody::StageFailed(fabro_types::StageFailedProps { - index: *index, - failure: Some(failure.clone()), - will_retry: *will_retry, + index: *index, + failure: Some(failure.clone()), + will_retry: *will_retry, + duration_ms: *duration_ms, }), Event::StageRetrying { index, @@ -2994,14 +2997,15 @@ mod tests { #[test] fn run_event_stage_failure_keeps_failure_detail() { let stored = to_run_event(&fixtures::RUN_3, &Event::StageFailed { - node_id: "code".to_string(), - name: "Code".to_string(), - index: 1, - failure: FailureDetail::new( + node_id: "code".to_string(), + name: "Code".to_string(), + index: 1, + failure: FailureDetail::new( "lint failed", crate::outcome::FailureCategory::Deterministic, ), - will_retry: true, + will_retry: true, + duration_ms: 5000, }); assert_eq!(stored.event_name(), "stage.failed"); diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs index ad55b8fdf..d709327da 100644 --- a/lib/crates/fabro-workflow/src/lib.rs +++ b/lib/crates/fabro-workflow/src/lib.rs @@ -92,7 +92,8 @@ pub fn extract_stage_durations_from_events(events: &[EventEnvelope]) -> HashMap< let mut durations = HashMap::new(); for envelope in events { let value = envelope.payload.as_value(); - if value.get("event").and_then(serde_json::Value::as_str) != Some("stage.completed") { + let event_name = value.get("event").and_then(serde_json::Value::as_str); + if event_name != Some("stage.completed") && event_name != Some("stage.failed") { continue; } let Some(node_id) = value.get("node_id").and_then(serde_json::Value::as_str) else { diff --git a/lib/crates/fabro-workflow/src/lifecycle/event.rs b/lib/crates/fabro-workflow/src/lifecycle/event.rs index 4b5348b22..273e6d733 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/event.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/event.rs @@ -209,15 +209,17 @@ impl RunLifecycle for EventLifecycle { let stage_index = state.stage_index; let scope = stage_scope_for(state, &gv.id); + let duration_ms = u64::try_from(ctx.result.duration.as_millis()).unwrap(); self.emitter.emit_scoped( &Event::StageFailed { - node_id: gv.id.clone(), - name: gv.label().to_string(), - index: stage_index, - failure: outcome.failure.clone().unwrap_or_else(|| { + node_id: gv.id.clone(), + name: gv.label().to_string(), + index: stage_index, + failure: outcome.failure.clone().unwrap_or_else(|| { FailureDetail::new("handler failed", FailureCategory::TransientInfra) }), will_retry: true, + duration_ms, }, &scope, ); @@ -260,13 +262,14 @@ impl RunLifecycle for EventLifecycle { if outcome.status == StageStatus::Fail { self.emitter.emit_scoped( &Event::StageFailed { - node_id: gv.id.clone(), - name: gv.label().to_string(), - index: stage_index, - failure: outcome.failure.clone().unwrap_or_else(|| { + node_id: gv.id.clone(), + name: gv.label().to_string(), + index: stage_index, + failure: outcome.failure.clone().unwrap_or_else(|| { FailureDetail::new("handler failed", FailureCategory::Deterministic) }), will_retry: false, + duration_ms, }, &scope, );