use crate::history::{DiagnosticHistory, DiagnosticLineage};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const DIAGNOSTIC_CASE_FILE_V1_SCHEMA: &str = "diagprint.forensics.case-file/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticCaseStatus {
Active,
Resolved,
}
impl DiagnosticCaseStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Resolved => "resolved",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosticCaseRun {
pub run_index: usize,
pub label: String,
pub report_digest: String,
pub run_digest: String,
pub instances: usize,
pub severities: BTreeMap<String, usize>,
pub diagnostic_digests: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosticEpisode {
pub episode: usize,
pub started_run: usize,
pub last_active_run: usize,
pub resolved_run: Option<usize>,
pub observed_runs: usize,
pub instances: usize,
pub peak_instances: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosticCaseFile {
pub schema: String,
pub fingerprint: String,
pub status: DiagnosticCaseStatus,
pub history_runs: usize,
pub observed_runs: usize,
pub observed_instances: usize,
pub unique_digests: usize,
pub first_seen_run: usize,
pub last_seen_run: usize,
pub active_instances: usize,
pub introduced_instances: usize,
pub resolved_instances: usize,
pub changed_instances: usize,
pub severity_increases: usize,
pub reappearances: usize,
pub chain_head: Option<String>,
pub episodes: Vec<DiagnosticEpisode>,
pub evidence: Vec<DiagnosticCaseRun>,
}
impl DiagnosticCaseFile {
pub fn from_history(history: &DiagnosticHistory, fingerprint: &str) -> Option<Self> {
let lineage = history.lineage(fingerprint);
let first_seen_run = lineage.first_seen_run()?;
let last_seen_run = lineage.last_seen_run()?;
let status = if lineage.active_in_latest() {
DiagnosticCaseStatus::Active
} else {
DiagnosticCaseStatus::Resolved
};
let active_instances = lineage.steps.last().map_or(0, |step| step.active_instances);
let severity_increases = lineage
.steps
.iter()
.map(|step| step.severity_increases)
.sum();
let mut unique_digests = BTreeSet::new();
let mut evidence = Vec::new();
for run in history.runs() {
let mut instances = 0usize;
let mut severities = BTreeMap::<String, usize>::new();
let mut run_digests = BTreeSet::<String>::new();
for observation in run
.observations
.iter()
.filter(|observation| observation.fingerprint == fingerprint)
{
instances = instances.saturating_add(1);
*severities.entry(observation.severity.clone()).or_default() += 1;
run_digests.insert(observation.digest.clone());
unique_digests.insert(observation.digest.clone());
}
if instances == 0 {
continue;
}
evidence.push(DiagnosticCaseRun {
run_index: run.index,
label: run.label.clone(),
report_digest: run.report_digest.clone(),
run_digest: run.run_digest.to_string(),
instances,
severities,
diagnostic_digests: run_digests.into_iter().collect(),
});
}
let episodes = build_episodes(&lineage);
let observed_instances = evidence.iter().map(|run| run.instances).sum();
let reappearances = episodes.len().saturating_sub(1);
Some(Self {
schema: DIAGNOSTIC_CASE_FILE_V1_SCHEMA.to_owned(),
fingerprint: fingerprint.to_owned(),
status,
history_runs: history.len(),
observed_runs: evidence.len(),
observed_instances,
unique_digests: unique_digests.len(),
first_seen_run,
last_seen_run,
active_instances,
introduced_instances: lineage.appearances(),
resolved_instances: lineage.resolutions(),
changed_instances: lineage.changes(),
severity_increases,
reappearances,
chain_head: history.head_digest().map(|digest| digest.to_string()),
episodes,
evidence,
})
}
pub const fn is_active(&self) -> bool {
matches!(self.status, DiagnosticCaseStatus::Active)
}
pub const fn is_resolved(&self) -> bool {
matches!(self.status, DiagnosticCaseStatus::Resolved)
}
}
impl DiagnosticHistory {
pub fn case_file(&self, fingerprint: &str) -> Option<DiagnosticCaseFile> {
DiagnosticCaseFile::from_history(self, fingerprint)
}
}
fn build_episodes(lineage: &DiagnosticLineage) -> Vec<DiagnosticEpisode> {
let mut episodes = Vec::new();
let mut current: Option<DiagnosticEpisode> = None;
for step in &lineage.steps {
if step.active_instances != 0 {
match current.as_mut() {
Some(episode) => {
episode.last_active_run = step.to_run;
episode.observed_runs = episode.observed_runs.saturating_add(1);
episode.instances = episode.instances.saturating_add(step.active_instances);
episode.peak_instances = episode.peak_instances.max(step.active_instances);
}
None => {
current = Some(DiagnosticEpisode {
episode: episodes.len() + 1,
started_run: step.to_run,
last_active_run: step.to_run,
resolved_run: None,
observed_runs: 1,
instances: step.active_instances,
peak_instances: step.active_instances,
});
}
}
continue;
}
if let Some(mut episode) = current.take() {
episode.resolved_run = Some(step.to_run);
episodes.push(episode);
}
}
if let Some(episode) = current {
episodes.push(episode);
}
episodes
}
pub const DIAGNOSTIC_TIMELINE_V1_SCHEMA: &str = "diagprint.forensics.timeline/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticTimelinePhase {
Unseen,
Active,
Absent,
}
impl DiagnosticTimelinePhase {
pub const fn as_str(self) -> &'static str {
match self {
Self::Unseen => "unseen",
Self::Active => "active",
Self::Absent => "absent",
}
}
pub const fn is_active(self) -> bool {
matches!(self, Self::Active)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticTimelineEvent {
FirstSeen,
Persisting,
Changed,
SeverityIncreased,
Resolved,
Reappeared,
}
impl DiagnosticTimelineEvent {
pub const fn as_str(self) -> &'static str {
match self {
Self::FirstSeen => "first_seen",
Self::Persisting => "persisting",
Self::Changed => "changed",
Self::SeverityIncreased => "severity_increased",
Self::Resolved => "resolved",
Self::Reappeared => "reappeared",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosticTimelineRun {
pub run_index: usize,
pub label: String,
pub phase: DiagnosticTimelinePhase,
pub episode: Option<usize>,
pub instances: usize,
pub severities: BTreeMap<String, usize>,
pub diagnostic_digests: Vec<String>,
pub introduced_instances: usize,
pub resolved_instances: usize,
pub persisting_instances: usize,
pub changed_instances: usize,
pub severity_increases: usize,
pub events: Vec<DiagnosticTimelineEvent>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticCleanWindowKind {
BeforeFirstSeen,
BetweenEpisodes,
AfterResolution,
}
impl DiagnosticCleanWindowKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::BeforeFirstSeen => "before_first_seen",
Self::BetweenEpisodes => "between_episodes",
Self::AfterResolution => "after_resolution",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosticCleanWindow {
pub kind: DiagnosticCleanWindowKind,
pub start_run: usize,
pub end_run: usize,
pub runs: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiagnosticTimeline {
pub schema: String,
pub fingerprint: String,
pub status: DiagnosticCaseStatus,
pub history_runs: usize,
pub first_seen_run: usize,
pub last_seen_run: usize,
pub active_instances: usize,
pub reappearances: usize,
pub chain_head: Option<String>,
pub runs: Vec<DiagnosticTimelineRun>,
pub clean_windows: Vec<DiagnosticCleanWindow>,
}
impl DiagnosticTimeline {
pub fn from_history(history: &DiagnosticHistory, fingerprint: &str) -> Option<Self> {
let case = history.case_file(fingerprint)?;
let lineage = history.lineage(fingerprint);
let mut runs = Vec::with_capacity(history.len());
let mut seen_before = false;
let mut previous_active = false;
let mut episode = 0usize;
for run in history.runs() {
let step = lineage.steps.iter().find(|step| step.to_run == run.index)?;
let mut instances = 0usize;
let mut severities = BTreeMap::<String, usize>::new();
let mut diagnostic_digests = BTreeSet::<String>::new();
for observation in run
.observations
.iter()
.filter(|observation| observation.fingerprint == fingerprint)
{
instances = instances.saturating_add(1);
*severities.entry(observation.severity.clone()).or_default() += 1;
diagnostic_digests.insert(observation.digest.clone());
}
let active = instances != 0;
let phase = if active {
DiagnosticTimelinePhase::Active
} else if seen_before {
DiagnosticTimelinePhase::Absent
} else {
DiagnosticTimelinePhase::Unseen
};
if active && !previous_active {
episode = episode.saturating_add(1);
}
let current_episode = active.then_some(episode);
let mut events = Vec::new();
if active {
if !seen_before {
events.push(DiagnosticTimelineEvent::FirstSeen);
} else if !previous_active {
events.push(DiagnosticTimelineEvent::Reappeared);
}
if step.counts.changed != 0 {
events.push(DiagnosticTimelineEvent::Changed);
}
if step.severity_increases != 0 {
events.push(DiagnosticTimelineEvent::SeverityIncreased);
}
if previous_active && events.is_empty() {
events.push(DiagnosticTimelineEvent::Persisting);
}
} else if previous_active {
events.push(DiagnosticTimelineEvent::Resolved);
}
runs.push(DiagnosticTimelineRun {
run_index: run.index,
label: run.label.clone(),
phase,
episode: current_episode,
instances,
severities,
diagnostic_digests: diagnostic_digests.into_iter().collect(),
introduced_instances: step.counts.new,
resolved_instances: step.counts.resolved,
persisting_instances: step.counts.persisting,
changed_instances: step.counts.changed,
severity_increases: step.severity_increases,
events,
});
if active {
seen_before = true;
}
previous_active = active;
}
let clean_windows = build_clean_windows(&runs, case.first_seen_run);
Some(Self {
schema: DIAGNOSTIC_TIMELINE_V1_SCHEMA.to_owned(),
fingerprint: fingerprint.to_owned(),
status: case.status,
history_runs: history.len(),
first_seen_run: case.first_seen_run,
last_seen_run: case.last_seen_run,
active_instances: case.active_instances,
reappearances: case.reappearances,
chain_head: case.chain_head,
runs,
clean_windows,
})
}
pub fn active_runs(&self) -> usize {
self.runs.iter().filter(|run| run.phase.is_active()).count()
}
pub fn absent_runs(&self) -> usize {
self.runs
.iter()
.filter(|run| matches!(run.phase, DiagnosticTimelinePhase::Absent))
.count()
}
pub fn unseen_runs(&self) -> usize {
self.runs
.iter()
.filter(|run| matches!(run.phase, DiagnosticTimelinePhase::Unseen))
.count()
}
}
impl DiagnosticHistory {
pub fn timeline(&self, fingerprint: &str) -> Option<DiagnosticTimeline> {
DiagnosticTimeline::from_history(self, fingerprint)
}
}
fn build_clean_windows(
runs: &[DiagnosticTimelineRun],
first_seen_run: usize,
) -> Vec<DiagnosticCleanWindow> {
let mut windows = Vec::new();
let mut index = 0usize;
while index < runs.len() {
if runs[index].phase.is_active() {
index += 1;
continue;
}
let start = index;
while index < runs.len() && !runs[index].phase.is_active() {
index += 1;
}
let end = index - 1;
let later_active = runs.iter().skip(index).any(|run| run.phase.is_active());
let kind = if runs[end].run_index < first_seen_run {
DiagnosticCleanWindowKind::BeforeFirstSeen
} else if later_active {
DiagnosticCleanWindowKind::BetweenEpisodes
} else {
DiagnosticCleanWindowKind::AfterResolution
};
windows.push(DiagnosticCleanWindow {
kind,
start_run: runs[start].run_index,
end_run: runs[end].run_index,
runs: end - start + 1,
});
}
windows
}