feat: populate RCA and postmortem docs with real timeline data
Add format_event_type() and calculate_duration() helpers to convert raw timeline events into human-readable tables and metrics. RCA now includes an Incident Timeline section and Incident Metrics (event count, duration, time-to-root-cause). Postmortem replaces placeholder timeline rows with real events, calculates impact duration, and auto-populates What Went Well from evidence. 10 new Rust tests covering timeline rendering, duration calculation, and event type formatting.
This commit is contained in:
parent
107fee8853
commit
79a623dbb2
@ -778,7 +778,9 @@ mod tests {
|
||||
fn test_timeline_events_indexes() {
|
||||
let conn = setup_test_db();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='timeline_events'")
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='timeline_events'",
|
||||
)
|
||||
.unwrap();
|
||||
let indexes: Vec<String> = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
|
||||
@ -130,14 +130,21 @@ pub struct TimelineEvent {
|
||||
}
|
||||
|
||||
impl TimelineEvent {
|
||||
pub fn new(issue_id: String, event_type: String, description: String, metadata: String) -> Self {
|
||||
pub fn new(
|
||||
issue_id: String,
|
||||
event_type: String,
|
||||
description: String,
|
||||
metadata: String,
|
||||
) -> Self {
|
||||
TimelineEvent {
|
||||
id: Uuid::now_v7().to_string(),
|
||||
issue_id,
|
||||
event_type,
|
||||
description,
|
||||
metadata,
|
||||
created_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||
created_at: chrono::Utc::now()
|
||||
.format("%Y-%m-%d %H:%M:%S UTC")
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use crate::db::models::IssueDetail;
|
||||
use crate::docs::rca::{calculate_duration, format_event_type};
|
||||
|
||||
pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String {
|
||||
let issue = &detail.issue;
|
||||
@ -51,7 +52,16 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String {
|
||||
|
||||
// Impact
|
||||
md.push_str("## Impact\n\n");
|
||||
md.push_str("- **Duration:** _[How long did the incident last?]_\n");
|
||||
if detail.timeline_events.len() >= 2 {
|
||||
let first = &detail.timeline_events[0].created_at;
|
||||
let last = &detail.timeline_events[detail.timeline_events.len() - 1].created_at;
|
||||
md.push_str(&format!(
|
||||
"- **Duration:** {}\n",
|
||||
calculate_duration(first, last)
|
||||
));
|
||||
} else {
|
||||
md.push_str("- **Duration:** _[How long did the incident last?]_\n");
|
||||
}
|
||||
md.push_str("- **Users Affected:** _[Number/percentage of affected users]_\n");
|
||||
md.push_str("- **Revenue Impact:** _[Financial impact, if applicable]_\n");
|
||||
md.push_str("- **SLA Impact:** _[Were any SLAs breached?]_\n\n");
|
||||
@ -67,7 +77,19 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String {
|
||||
if let Some(ref resolved) = issue.resolved_at {
|
||||
md.push_str(&format!("| {resolved} | Issue resolved |\n"));
|
||||
}
|
||||
md.push_str("| _HH:MM_ | _[Add additional timeline events]_ |\n\n");
|
||||
if detail.timeline_events.is_empty() {
|
||||
md.push_str("| _HH:MM_ | _[Add additional timeline events]_ |\n");
|
||||
} else {
|
||||
for event in &detail.timeline_events {
|
||||
md.push_str(&format!(
|
||||
"| {} | {} - {} |\n",
|
||||
event.created_at,
|
||||
format_event_type(&event.event_type),
|
||||
event.description
|
||||
));
|
||||
}
|
||||
}
|
||||
md.push('\n');
|
||||
|
||||
// Root Cause Analysis
|
||||
md.push_str("## Root Cause Analysis\n\n");
|
||||
@ -114,6 +136,19 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String {
|
||||
|
||||
// What Went Well
|
||||
md.push_str("## What Went Well\n\n");
|
||||
if !detail.resolution_steps.is_empty() {
|
||||
md.push_str(&format!(
|
||||
"- Systematic 5-whys analysis conducted ({} steps completed)\n",
|
||||
detail.resolution_steps.len()
|
||||
));
|
||||
}
|
||||
if detail
|
||||
.timeline_events
|
||||
.iter()
|
||||
.any(|e| e.event_type == "root_cause_identified")
|
||||
{
|
||||
md.push_str("- Root cause was identified during triage\n");
|
||||
}
|
||||
md.push_str("- _[e.g., Quick detection through existing alerts]_\n");
|
||||
md.push_str("- _[e.g., Effective cross-team collaboration]_\n");
|
||||
md.push_str("- _[e.g., Smooth communication with stakeholders]_\n\n");
|
||||
@ -158,7 +193,7 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::models::{Issue, IssueDetail, ResolutionStep};
|
||||
use crate::db::models::{Issue, IssueDetail, ResolutionStep, TimelineEvent};
|
||||
|
||||
fn make_test_detail() -> IssueDetail {
|
||||
IssueDetail {
|
||||
@ -247,4 +282,76 @@ mod tests {
|
||||
assert!(md.contains("| Priority | Action | Owner | Due Date | Status |"));
|
||||
assert!(md.contains("| P0 |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_postmortem_timeline_with_real_events() {
|
||||
let mut detail = make_test_detail();
|
||||
detail.timeline_events = vec![
|
||||
TimelineEvent {
|
||||
id: "te-1".to_string(),
|
||||
issue_id: "pm-456".to_string(),
|
||||
event_type: "triage_started".to_string(),
|
||||
description: "Triage initiated".to_string(),
|
||||
metadata: "{}".to_string(),
|
||||
created_at: "2025-02-10 08:05:00 UTC".to_string(),
|
||||
},
|
||||
TimelineEvent {
|
||||
id: "te-2".to_string(),
|
||||
issue_id: "pm-456".to_string(),
|
||||
event_type: "root_cause_identified".to_string(),
|
||||
description: "Certificate expiry confirmed".to_string(),
|
||||
metadata: "{}".to_string(),
|
||||
created_at: "2025-02-10 10:30:00 UTC".to_string(),
|
||||
},
|
||||
];
|
||||
let md = generate_postmortem_markdown(&detail);
|
||||
assert!(md.contains("## Timeline"));
|
||||
assert!(md.contains("| 2025-02-10 08:05:00 UTC | Triage Started - Triage initiated |"));
|
||||
assert!(md.contains(
|
||||
"| 2025-02-10 10:30:00 UTC | Root Cause Identified - Certificate expiry confirmed |"
|
||||
));
|
||||
assert!(!md.contains("_[Add additional timeline events]_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_postmortem_impact_with_duration() {
|
||||
let mut detail = make_test_detail();
|
||||
detail.timeline_events = vec![
|
||||
TimelineEvent {
|
||||
id: "te-1".to_string(),
|
||||
issue_id: "pm-456".to_string(),
|
||||
event_type: "triage_started".to_string(),
|
||||
description: "Triage initiated".to_string(),
|
||||
metadata: "{}".to_string(),
|
||||
created_at: "2025-02-10 08:00:00 UTC".to_string(),
|
||||
},
|
||||
TimelineEvent {
|
||||
id: "te-2".to_string(),
|
||||
issue_id: "pm-456".to_string(),
|
||||
event_type: "root_cause_identified".to_string(),
|
||||
description: "Found it".to_string(),
|
||||
metadata: "{}".to_string(),
|
||||
created_at: "2025-02-10 10:30:00 UTC".to_string(),
|
||||
},
|
||||
];
|
||||
let md = generate_postmortem_markdown(&detail);
|
||||
assert!(md.contains("**Duration:** 2h 30m"));
|
||||
assert!(!md.contains("_[How long did the incident last?]_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_postmortem_what_went_well_with_steps() {
|
||||
let mut detail = make_test_detail();
|
||||
detail.timeline_events = vec![TimelineEvent {
|
||||
id: "te-1".to_string(),
|
||||
issue_id: "pm-456".to_string(),
|
||||
event_type: "root_cause_identified".to_string(),
|
||||
description: "Root cause found".to_string(),
|
||||
metadata: "{}".to_string(),
|
||||
created_at: "2025-02-10 10:00:00 UTC".to_string(),
|
||||
}];
|
||||
let md = generate_postmortem_markdown(&detail);
|
||||
assert!(md.contains("Systematic 5-whys analysis conducted (1 steps completed)"));
|
||||
assert!(md.contains("Root cause was identified during triage"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,48 @@
|
||||
use crate::db::models::IssueDetail;
|
||||
|
||||
pub fn format_event_type(event_type: &str) -> &str {
|
||||
match event_type {
|
||||
"triage_started" => "Triage Started",
|
||||
"log_uploaded" => "Log File Uploaded",
|
||||
"why_level_advanced" => "Why Level Advanced",
|
||||
"root_cause_identified" => "Root Cause Identified",
|
||||
"rca_generated" => "RCA Document Generated",
|
||||
"postmortem_generated" => "Post-Mortem Generated",
|
||||
"document_exported" => "Document Exported",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_duration(start: &str, end: &str) -> String {
|
||||
let fmt = "%Y-%m-%d %H:%M:%S UTC";
|
||||
let start_dt = match chrono::NaiveDateTime::parse_from_str(start, fmt) {
|
||||
Ok(dt) => dt,
|
||||
Err(_) => return "N/A".to_string(),
|
||||
};
|
||||
let end_dt = match chrono::NaiveDateTime::parse_from_str(end, fmt) {
|
||||
Ok(dt) => dt,
|
||||
Err(_) => return "N/A".to_string(),
|
||||
};
|
||||
|
||||
let duration = end_dt.signed_duration_since(start_dt);
|
||||
let total_minutes = duration.num_minutes();
|
||||
if total_minutes < 0 {
|
||||
return "N/A".to_string();
|
||||
}
|
||||
|
||||
let days = total_minutes / (24 * 60);
|
||||
let hours = (total_minutes % (24 * 60)) / 60;
|
||||
let minutes = total_minutes % 60;
|
||||
|
||||
if days > 0 {
|
||||
format!("{days}d {hours}h")
|
||||
} else if hours > 0 {
|
||||
format!("{hours}h {minutes}m")
|
||||
} else {
|
||||
format!("{minutes}m")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_rca_markdown(detail: &IssueDetail) -> String {
|
||||
let issue = &detail.issue;
|
||||
|
||||
@ -57,6 +100,52 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String {
|
||||
md.push_str("\n\n");
|
||||
}
|
||||
|
||||
// Incident Timeline
|
||||
md.push_str("## Incident Timeline\n\n");
|
||||
if detail.timeline_events.is_empty() {
|
||||
md.push_str("_No timeline events recorded._\n\n");
|
||||
} else {
|
||||
md.push_str("| Time (UTC) | Event | Description |\n");
|
||||
md.push_str("|------------|-------|-------------|\n");
|
||||
for event in &detail.timeline_events {
|
||||
md.push_str(&format!(
|
||||
"| {} | {} | {} |\n",
|
||||
event.created_at,
|
||||
format_event_type(&event.event_type),
|
||||
event.description
|
||||
));
|
||||
}
|
||||
md.push('\n');
|
||||
}
|
||||
|
||||
// Incident Metrics
|
||||
md.push_str("## Incident Metrics\n\n");
|
||||
md.push_str(&format!(
|
||||
"- **Total Events:** {}\n",
|
||||
detail.timeline_events.len()
|
||||
));
|
||||
if detail.timeline_events.len() >= 2 {
|
||||
let first = &detail.timeline_events[0].created_at;
|
||||
let last = &detail.timeline_events[detail.timeline_events.len() - 1].created_at;
|
||||
md.push_str(&format!(
|
||||
"- **Incident Duration:** {}\n",
|
||||
calculate_duration(first, last)
|
||||
));
|
||||
} else {
|
||||
md.push_str("- **Incident Duration:** N/A\n");
|
||||
}
|
||||
let root_cause_event = detail
|
||||
.timeline_events
|
||||
.iter()
|
||||
.find(|e| e.event_type == "root_cause_identified");
|
||||
if let (Some(first), Some(rc)) = (detail.timeline_events.first(), root_cause_event) {
|
||||
md.push_str(&format!(
|
||||
"- **Time to Root Cause:** {}\n",
|
||||
calculate_duration(&first.created_at, &rc.created_at)
|
||||
));
|
||||
}
|
||||
md.push('\n');
|
||||
|
||||
// 5 Whys Analysis
|
||||
md.push_str("## 5 Whys Analysis\n\n");
|
||||
if detail.resolution_steps.is_empty() {
|
||||
@ -143,7 +232,7 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::models::{Issue, IssueDetail, LogFile, ResolutionStep};
|
||||
use crate::db::models::{Issue, IssueDetail, LogFile, ResolutionStep, TimelineEvent};
|
||||
|
||||
fn make_test_detail() -> IssueDetail {
|
||||
IssueDetail {
|
||||
@ -248,4 +337,135 @@ mod tests {
|
||||
let md = generate_rca_markdown(&detail);
|
||||
assert!(md.contains("Unassigned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rca_timeline_section_with_events() {
|
||||
let mut detail = make_test_detail();
|
||||
detail.timeline_events = vec![
|
||||
TimelineEvent {
|
||||
id: "te-1".to_string(),
|
||||
issue_id: "test-123".to_string(),
|
||||
event_type: "triage_started".to_string(),
|
||||
description: "Triage initiated by oncall".to_string(),
|
||||
metadata: "{}".to_string(),
|
||||
created_at: "2025-01-15 10:00:00 UTC".to_string(),
|
||||
},
|
||||
TimelineEvent {
|
||||
id: "te-2".to_string(),
|
||||
issue_id: "test-123".to_string(),
|
||||
event_type: "log_uploaded".to_string(),
|
||||
description: "app.log uploaded".to_string(),
|
||||
metadata: "{}".to_string(),
|
||||
created_at: "2025-01-15 10:30:00 UTC".to_string(),
|
||||
},
|
||||
TimelineEvent {
|
||||
id: "te-3".to_string(),
|
||||
issue_id: "test-123".to_string(),
|
||||
event_type: "root_cause_identified".to_string(),
|
||||
description: "Connection pool leak found".to_string(),
|
||||
metadata: "{}".to_string(),
|
||||
created_at: "2025-01-15 12:15:00 UTC".to_string(),
|
||||
},
|
||||
];
|
||||
let md = generate_rca_markdown(&detail);
|
||||
assert!(md.contains("## Incident Timeline"));
|
||||
assert!(md.contains("| Time (UTC) | Event | Description |"));
|
||||
assert!(md
|
||||
.contains("| 2025-01-15 10:00:00 UTC | Triage Started | Triage initiated by oncall |"));
|
||||
assert!(md.contains("| 2025-01-15 10:30:00 UTC | Log File Uploaded | app.log uploaded |"));
|
||||
assert!(md.contains(
|
||||
"| 2025-01-15 12:15:00 UTC | Root Cause Identified | Connection pool leak found |"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rca_timeline_section_empty() {
|
||||
let detail = make_test_detail();
|
||||
let md = generate_rca_markdown(&detail);
|
||||
assert!(md.contains("## Incident Timeline"));
|
||||
assert!(md.contains("_No timeline events recorded._"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rca_metrics_section() {
|
||||
let mut detail = make_test_detail();
|
||||
detail.timeline_events = vec![
|
||||
TimelineEvent {
|
||||
id: "te-1".to_string(),
|
||||
issue_id: "test-123".to_string(),
|
||||
event_type: "triage_started".to_string(),
|
||||
description: "Triage started".to_string(),
|
||||
metadata: "{}".to_string(),
|
||||
created_at: "2025-01-15 10:00:00 UTC".to_string(),
|
||||
},
|
||||
TimelineEvent {
|
||||
id: "te-2".to_string(),
|
||||
issue_id: "test-123".to_string(),
|
||||
event_type: "root_cause_identified".to_string(),
|
||||
description: "Root cause found".to_string(),
|
||||
metadata: "{}".to_string(),
|
||||
created_at: "2025-01-15 12:15:00 UTC".to_string(),
|
||||
},
|
||||
];
|
||||
let md = generate_rca_markdown(&detail);
|
||||
assert!(md.contains("## Incident Metrics"));
|
||||
assert!(md.contains("**Total Events:** 2"));
|
||||
assert!(md.contains("**Incident Duration:** 2h 15m"));
|
||||
assert!(md.contains("**Time to Root Cause:** 2h 15m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_duration_hours_minutes() {
|
||||
assert_eq!(
|
||||
calculate_duration("2025-01-15 10:00:00 UTC", "2025-01-15 12:15:00 UTC"),
|
||||
"2h 15m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_duration_days() {
|
||||
assert_eq!(
|
||||
calculate_duration("2025-01-15 10:00:00 UTC", "2025-01-18 11:00:00 UTC"),
|
||||
"3d 1h"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_duration_minutes_only() {
|
||||
assert_eq!(
|
||||
calculate_duration("2025-01-15 10:00:00 UTC", "2025-01-15 10:45:00 UTC"),
|
||||
"45m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_duration_invalid() {
|
||||
assert_eq!(calculate_duration("bad-date", "also-bad"), "N/A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_event_type_known() {
|
||||
assert_eq!(format_event_type("triage_started"), "Triage Started");
|
||||
assert_eq!(format_event_type("log_uploaded"), "Log File Uploaded");
|
||||
assert_eq!(
|
||||
format_event_type("why_level_advanced"),
|
||||
"Why Level Advanced"
|
||||
);
|
||||
assert_eq!(
|
||||
format_event_type("root_cause_identified"),
|
||||
"Root Cause Identified"
|
||||
);
|
||||
assert_eq!(format_event_type("rca_generated"), "RCA Document Generated");
|
||||
assert_eq!(
|
||||
format_event_type("postmortem_generated"),
|
||||
"Post-Mortem Generated"
|
||||
);
|
||||
assert_eq!(format_event_type("document_exported"), "Document Exported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_event_type_unknown() {
|
||||
assert_eq!(format_event_type("custom_event"), "custom_event");
|
||||
assert_eq!(format_event_type(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user