Skip to main content

xbp_analytics/
lib.rs

1#![forbid(unsafe_code)]
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use xbp_delivery::{DeliveryEvent, DeliveryEventKind};
6
7#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
8pub struct Hours(pub f64);
9
10#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
11pub struct Days(pub f64);
12
13#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
14pub struct RatePerDay(pub f64);
15
16#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
17pub struct Probability(pub f64);
18
19#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
20pub struct Coverage(pub f64);
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum AnalyticsError {
24    EmptySample,
25    InvalidProbability,
26    InvalidWindow,
27    NegativeDuration,
28    NonFiniteValue,
29    InvalidEvidenceCoverage,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum AnalyticsSubjectKind {
35    Repository,
36    Service,
37    Project,
38    Initiative,
39    Portfolio,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub struct AnalyticsSubject {
44    pub kind: AnalyticsSubjectKind,
45    pub id: String,
46}
47
48impl AnalyticsSubject {
49    pub fn new(kind: AnalyticsSubjectKind, id: impl Into<String>) -> Self {
50        Self {
51            kind,
52            id: id.into(),
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum TrendDirection {
60    Improving,
61    Stable,
62    Declining,
63    Volatile,
64    StructuralBreak,
65    Unknown,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
69pub struct DoraTrend {
70    pub current: Option<f64>,
71    pub previous: Option<f64>,
72    pub delta_absolute: Option<f64>,
73    pub delta_relative: Option<f64>,
74    pub trend: TrendDirection,
75    pub confidence: f64,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct DoraComparison {
80    pub subject: AnalyticsSubject,
81    pub deployment_frequency: DoraTrend,
82    pub lead_time: DoraTrend,
83    pub change_failure_rate: DoraTrend,
84    pub recovery_time: DoraTrend,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum LittleLawStatus {
90    Consistent,
91    Divergent,
92    Insufficient,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
96pub struct LittleLawDiagnostic {
97    pub wip: usize,
98    pub throughput_per_day: f64,
99    pub cycle_time_hours: f64,
100    pub expected_wip: f64,
101    pub relative_error: f64,
102    pub status: LittleLawStatus,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum WipBand {
108    Normal,
109    Elevated,
110    Overloaded,
111    Unknown,
112}
113
114pub fn classify_wip_band(wip: usize, throughput_per_day: f64, cycle_time_hours: f64) -> WipBand {
115    if throughput_per_day <= 0.0 || cycle_time_hours <= 0.0 {
116        return WipBand::Unknown;
117    }
118    let expected = throughput_per_day * cycle_time_hours / 24.0;
119    if expected <= 0.0 {
120        WipBand::Unknown
121    } else {
122        let ratio = wip as f64 / expected;
123        if ratio <= 1.25 {
124            WipBand::Normal
125        } else if ratio <= 2.0 {
126            WipBand::Elevated
127        } else {
128            WipBand::Overloaded
129        }
130    }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
134pub struct WipAgeAssessment {
135    pub age_hours: f64,
136    pub historical_p85_hours: Option<f64>,
137    pub percentile: Option<f64>,
138    pub aging: bool,
139}
140
141pub fn assess_wip_age(age_hours: f64, historical_cycle_times_hours: &[f64]) -> WipAgeAssessment {
142    let mut sorted = historical_cycle_times_hours
143        .iter()
144        .copied()
145        .filter(|value| value.is_finite() && *value >= 0.0)
146        .collect::<Vec<_>>();
147    sorted.sort_by(f64::total_cmp);
148    let historical_p85_hours = if sorted.is_empty() {
149        None
150    } else {
151        let index = ((sorted.len() as f64 * 0.85).ceil() as usize)
152            .saturating_sub(1)
153            .min(sorted.len() - 1);
154        Some(sorted[index])
155    };
156    let percentile = if sorted.is_empty() {
157        None
158    } else {
159        Some(
160            sorted.iter().filter(|value| **value <= age_hours).count() as f64 / sorted.len() as f64,
161        )
162    };
163    WipAgeAssessment {
164        age_hours,
165        historical_p85_hours,
166        percentile,
167        aging: historical_p85_hours.is_some_and(|value| age_hours > value),
168    }
169}
170
171pub fn little_law_diagnostic(
172    wip: usize,
173    throughput_per_day: f64,
174    cycle_time_hours: f64,
175) -> LittleLawDiagnostic {
176    let expected_wip = throughput_per_day * cycle_time_hours / 24.0;
177    let relative_error = if expected_wip == 0.0 {
178        if wip == 0 {
179            0.0
180        } else {
181            1.0
182        }
183    } else {
184        (wip as f64 - expected_wip).abs() / expected_wip.abs()
185    };
186    LittleLawDiagnostic {
187        wip,
188        throughput_per_day,
189        cycle_time_hours,
190        expected_wip,
191        relative_error,
192        status: if throughput_per_day <= 0.0 || cycle_time_hours <= 0.0 {
193            LittleLawStatus::Insufficient
194        } else if relative_error <= 0.25 {
195            LittleLawStatus::Consistent
196        } else {
197            LittleLawStatus::Divergent
198        },
199    }
200}
201
202pub fn dora_comparison(current: &DoraMetrics, previous: &DoraMetrics) -> DoraComparison {
203    let subject = current
204        .subject
205        .clone()
206        .unwrap_or_else(|| AnalyticsSubject::new(AnalyticsSubjectKind::Project, "unknown"));
207    DoraComparison {
208        subject,
209        deployment_frequency: dora_trend(
210            current.deployment_frequency_per_week,
211            previous.deployment_frequency_per_week,
212            false,
213        ),
214        lead_time: dora_trend(
215            current.lead_time_for_changes_hours,
216            previous.lead_time_for_changes_hours,
217            true,
218        ),
219        change_failure_rate: dora_trend(
220            current.change_failure_rate,
221            previous.change_failure_rate,
222            true,
223        ),
224        recovery_time: dora_trend(
225            current.failed_deployment_recovery_hours,
226            previous.failed_deployment_recovery_hours,
227            true,
228        ),
229    }
230}
231
232fn dora_trend(current: Option<f64>, previous: Option<f64>, lower_is_better: bool) -> DoraTrend {
233    let delta_absolute = current
234        .zip(previous)
235        .map(|(current, previous)| current - previous);
236    let delta_relative = current
237        .zip(previous)
238        .filter(|(_, previous)| *previous != 0.0)
239        .map(|(current, previous)| (current - previous) / previous.abs());
240    let trend = match delta_relative {
241        None => TrendDirection::Unknown,
242        Some(delta) if delta.abs() < 0.10 => TrendDirection::Stable,
243        Some(delta) if (delta < 0.0) == lower_is_better => TrendDirection::Improving,
244        Some(_) => TrendDirection::Declining,
245    };
246    DoraTrend {
247        current,
248        previous,
249        delta_absolute,
250        delta_relative,
251        trend,
252        confidence: if current.is_some() && previous.is_some() {
253            1.0
254        } else {
255            0.0
256        },
257    }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
261pub struct TimeWindow {
262    pub start: DateTime<Utc>,
263    pub end: DateTime<Utc>,
264}
265
266impl TimeWindow {
267    pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
268        Self { start, end }
269    }
270
271    pub fn days(&self) -> Result<f64, AnalyticsError> {
272        let seconds = (self.end - self.start).num_seconds();
273        if seconds <= 0 {
274            return Err(AnalyticsError::InvalidWindow);
275        }
276        Ok(seconds as f64 / 86_400.0)
277    }
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
281pub enum PercentileMethod {
282    NearestRank,
283}
284
285#[derive(Debug, Clone, PartialEq, Serialize)]
286pub struct DistributionSummary {
287    pub sample_count: usize,
288    pub method: PercentileMethod,
289    pub min: Option<f64>,
290    pub max: Option<f64>,
291    pub mean: Option<f64>,
292    pub median: Option<f64>,
293    pub p50: Option<f64>,
294    pub p75: Option<f64>,
295    pub p85: Option<f64>,
296    pub p90: Option<f64>,
297    pub p95: Option<f64>,
298    pub p99: Option<f64>,
299    pub stddev: Option<f64>,
300    pub mad: Option<f64>,
301    pub iqr: Option<f64>,
302}
303
304impl DistributionSummary {
305    pub fn from_samples(values: &[f64]) -> Result<Self, AnalyticsError> {
306        validate_values(values)?;
307        if values.is_empty() {
308            return Ok(Self {
309                sample_count: 0,
310                method: PercentileMethod::NearestRank,
311                min: None,
312                max: None,
313                mean: None,
314                median: None,
315                p50: None,
316                p75: None,
317                p85: None,
318                p90: None,
319                p95: None,
320                p99: None,
321                stddev: None,
322                mad: None,
323                iqr: None,
324            });
325        }
326        Ok(Self {
327            sample_count: values.len(),
328            method: PercentileMethod::NearestRank,
329            min: Some(values.iter().copied().fold(f64::INFINITY, f64::min)),
330            max: Some(values.iter().copied().fold(f64::NEG_INFINITY, f64::max)),
331            mean: Some(mean(values)?),
332            median: Some(percentile(values, 0.5)?),
333            p50: Some(percentile(values, 0.5)?),
334            p75: Some(percentile(values, 0.75)?),
335            p85: Some(percentile(values, 0.85)?),
336            p90: Some(percentile(values, 0.90)?),
337            p95: Some(percentile(values, 0.95)?),
338            p99: Some(percentile(values, 0.99)?),
339            stddev: Some(variance(values)?.sqrt()),
340            mad: Some(mad(values)?),
341            iqr: Some(percentile(values, 0.75)? - percentile(values, 0.25)?),
342        })
343    }
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
347#[serde(rename_all = "snake_case")]
348pub enum CoverageAvailability {
349    Available,
350    #[default]
351    Unavailable,
352}
353
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct EvidenceCoverage {
356    pub eligible: usize,
357    pub observed: usize,
358    pub sufficient: usize,
359    pub ratio: f64,
360    #[serde(default)]
361    pub availability: CoverageAvailability,
362}
363
364impl EvidenceCoverage {
365    pub fn new(eligible: usize, observed: usize, sufficient: usize) -> Self {
366        Self::try_new(eligible, observed, sufficient)
367            .expect("evidence coverage denominators must be ordered")
368    }
369
370    pub fn try_new(
371        eligible: usize,
372        observed: usize,
373        sufficient: usize,
374    ) -> Result<Self, AnalyticsError> {
375        if observed > eligible || sufficient > observed {
376            return Err(AnalyticsError::InvalidEvidenceCoverage);
377        }
378        Ok(Self {
379            eligible,
380            observed,
381            sufficient,
382            ratio: if eligible == 0 {
383                0.0
384            } else {
385                sufficient as f64 / eligible as f64
386            },
387            availability: if eligible > 0 && sufficient > 0 {
388                CoverageAvailability::Available
389            } else {
390                CoverageAvailability::Unavailable
391            },
392        })
393    }
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(rename_all = "snake_case")]
398pub enum DataQualityWarning {
399    InsufficientHistory,
400    IncompleteContributorBinding,
401    MissingDeploymentBindings,
402    StaleProviderData,
403    PartialStateHistory,
404    LowDeploymentCorrelationCoverage,
405}
406
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408pub struct DataQuality {
409    pub coverage: EvidenceCoverage,
410    pub freshness_seconds: Option<i64>,
411    pub effective_sample_size: f64,
412    pub missing_fields: Vec<String>,
413    pub warnings: Vec<DataQualityWarning>,
414}
415
416impl DataQuality {
417    pub fn new(
418        coverage: EvidenceCoverage,
419        freshness_seconds: Option<i64>,
420        effective_sample_size: f64,
421        missing_fields: Vec<String>,
422        warnings: Vec<DataQualityWarning>,
423    ) -> Self {
424        Self {
425            coverage,
426            freshness_seconds,
427            effective_sample_size,
428            missing_fields,
429            warnings,
430        }
431    }
432}
433
434#[derive(Debug, Clone, PartialEq)]
435pub struct FlowSamples<'a> {
436    pub lead_times_hours: &'a [f64],
437    pub cycle_times_hours: &'a [f64],
438    pub active_times_hours: &'a [f64],
439    pub blocked_times_hours: &'a [f64],
440    pub review_wait_times_hours: &'a [f64],
441    pub deployment_wait_times_hours: &'a [f64],
442}
443
444#[derive(Debug, Clone, PartialEq, Serialize)]
445pub struct FlowMetrics {
446    pub window: TimeWindow,
447    pub completed_count: usize,
448    pub eligible_completed_count: usize,
449    pub observed_completed_count: usize,
450    pub valid_completed_count: usize,
451    pub arrival_count: usize,
452    pub wip: usize,
453    pub blocked_wip: usize,
454    pub throughput_per_day: f64,
455    pub arrival_rate_per_day: f64,
456    pub queue_growth_per_day: f64,
457    pub lead_time_hours: DistributionSummary,
458    pub cycle_time_hours: DistributionSummary,
459    pub active_time_hours: DistributionSummary,
460    pub blocked_time_hours: DistributionSummary,
461    pub review_wait_hours: DistributionSummary,
462    pub deployment_wait_hours: DistributionSummary,
463    pub flow_efficiency: Option<f64>,
464    pub sample_count: usize,
465    pub coverage: EvidenceCoverage,
466}
467
468impl FlowMetrics {
469    pub const SCHEMA_V1: &'static str = "xbp.analytics.flow/v1";
470    pub const SCHEMA_V2: &'static str = "xbp.analytics.flow/v2";
471
472    pub fn from_samples(
473        window: TimeWindow,
474        completed_count: usize,
475        arrival_count: usize,
476        wip: usize,
477        samples: FlowSamples<'_>,
478    ) -> Result<Self, AnalyticsError> {
479        Self::from_samples_with_coverage(
480            window,
481            completed_count,
482            completed_count,
483            completed_count,
484            arrival_count,
485            wip,
486            0,
487            samples,
488        )
489    }
490
491    #[allow(clippy::too_many_arguments)]
492    pub fn from_samples_with_coverage(
493        window: TimeWindow,
494        eligible_completed_count: usize,
495        observed_completed_count: usize,
496        valid_completed_count: usize,
497        arrival_count: usize,
498        wip: usize,
499        blocked_wip: usize,
500        samples: FlowSamples<'_>,
501    ) -> Result<Self, AnalyticsError> {
502        reject_negative_durations(&[
503            samples.lead_times_hours,
504            samples.cycle_times_hours,
505            samples.active_times_hours,
506            samples.blocked_times_hours,
507            samples.review_wait_times_hours,
508            samples.deployment_wait_times_hours,
509        ])?;
510        let window_days = window.days()?;
511        let cycle = DistributionSummary::from_samples(samples.cycle_times_hours)?;
512        let active = DistributionSummary::from_samples(samples.active_times_hours)?;
513        let flow_efficiency = match (active.mean, cycle.mean) {
514            (Some(active), Some(cycle)) if cycle > 0.0 => Some(active / cycle),
515            _ => None,
516        };
517        let coverage = EvidenceCoverage::try_new(
518            eligible_completed_count,
519            observed_completed_count,
520            valid_completed_count,
521        )?;
522        Ok(Self {
523            window,
524            completed_count: valid_completed_count,
525            eligible_completed_count,
526            observed_completed_count,
527            valid_completed_count,
528            arrival_count,
529            wip,
530            blocked_wip,
531            throughput_per_day: valid_completed_count as f64 / window_days,
532            arrival_rate_per_day: arrival_count as f64 / window_days,
533            queue_growth_per_day: (arrival_count as f64 - valid_completed_count as f64)
534                / window_days,
535            lead_time_hours: DistributionSummary::from_samples(samples.lead_times_hours)?,
536            cycle_time_hours: cycle,
537            active_time_hours: active,
538            blocked_time_hours: DistributionSummary::from_samples(samples.blocked_times_hours)?,
539            review_wait_hours: DistributionSummary::from_samples(samples.review_wait_times_hours)?,
540            deployment_wait_hours: DistributionSummary::from_samples(
541                samples.deployment_wait_times_hours,
542            )?,
543            flow_efficiency,
544            sample_count: samples.lead_times_hours.len(),
545            coverage,
546        })
547    }
548}
549
550#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
551pub struct FlowMetricsV1 {
552    pub throughput: f64,
553    pub arrival_rate: f64,
554    pub wip: usize,
555    pub queue_growth: f64,
556    pub lead_time: Option<f64>,
557    pub cycle_time: Option<f64>,
558    pub active_time: Option<f64>,
559    pub blocked_time: Option<f64>,
560    pub review_wait: Option<f64>,
561    pub deployment_wait: Option<f64>,
562    pub flow_efficiency: Option<f64>,
563    pub sample_count: usize,
564}
565
566impl FlowMetrics {
567    pub fn to_v1(&self) -> FlowMetricsV1 {
568        FlowMetricsV1 {
569            throughput: self.throughput_per_day,
570            arrival_rate: self.arrival_rate_per_day,
571            wip: self.wip,
572            queue_growth: self.queue_growth_per_day,
573            lead_time: self.lead_time_hours.mean,
574            cycle_time: self.cycle_time_hours.mean,
575            active_time: self.active_time_hours.mean,
576            blocked_time: self.blocked_time_hours.mean,
577            review_wait: self.review_wait_hours.mean,
578            deployment_wait: self.deployment_wait_hours.mean,
579            flow_efficiency: self.flow_efficiency,
580            sample_count: self.sample_count,
581        }
582    }
583}
584
585pub type FlowProjection = FlowMetrics;
586
587impl FlowMetrics {
588    pub fn from_events(events: &[DeliveryEvent], window: TimeWindow) -> Result<Self, String> {
589        use std::collections::{BTreeMap, BTreeSet};
590
591        window
592            .days()
593            .map_err(|error| format!("invalid flow window: {error:?}"))?;
594        let mut histories: BTreeMap<String, Vec<&DeliveryEvent>> = BTreeMap::new();
595        for event in events
596            .iter()
597            .filter(|event| event.occurred_at <= window.end)
598        {
599            let Some(work_item_id) =
600                event
601                    .work_item_id
602                    .as_ref()
603                    .map(|id| id.0.clone())
604                    .or_else(|| {
605                        event
606                            .attributes
607                            .get("work_item_id")
608                            .and_then(|value| value.as_str())
609                            .map(str::to_string)
610                    })
611            else {
612                continue;
613            };
614            histories.entry(work_item_id).or_default().push(event);
615        }
616        for history in histories.values_mut() {
617            history.sort_by_key(|event| (event.occurred_at, event.id.clone()));
618        }
619
620        let completed_ids = histories
621            .iter()
622            .filter(|(_, history)| {
623                history.iter().any(|event| {
624                    event.kind == DeliveryEventKind::WorkItemCompleted
625                        && event.occurred_at >= window.start
626                        && event.occurred_at <= window.end
627                })
628            })
629            .map(|(id, _)| id.clone())
630            .collect::<BTreeSet<_>>();
631        let arrival_count = histories
632            .values()
633            .filter(|history| {
634                history.iter().any(|event| {
635                    event.kind == DeliveryEventKind::WorkItemCreated
636                        && event.occurred_at >= window.start
637                        && event.occurred_at <= window.end
638                })
639            })
640            .count();
641
642        let mut lead_times = Vec::new();
643        let mut cycle_times = Vec::new();
644        let mut blocked_times = Vec::new();
645        let mut observed_completed_count = 0;
646        let mut blocked_wip = 0;
647
648        for (work_item_id, history) in &histories {
649            let created = history
650                .iter()
651                .find(|event| event.kind == DeliveryEventKind::WorkItemCreated)
652                .map(|event| event.occurred_at);
653            let started = history
654                .iter()
655                .find(|event| event.kind == DeliveryEventKind::WorkItemStarted)
656                .map(|event| event.occurred_at);
657            let completed = history
658                .iter()
659                .find(|event| event.kind == DeliveryEventKind::WorkItemCompleted)
660                .map(|event| event.occurred_at);
661
662            let mut is_blocked = false;
663            for event in history {
664                match event.kind {
665                    DeliveryEventKind::WorkItemBlocked if !is_blocked => is_blocked = true,
666                    DeliveryEventKind::WorkItemUnblocked if is_blocked => is_blocked = false,
667                    DeliveryEventKind::WorkItemBlocked => {
668                        return Err(format!(
669                            "duplicate block transition for work item {}",
670                            event
671                                .work_item_id
672                                .as_ref()
673                                .map(|id| id.0.as_str())
674                                .unwrap_or("unknown")
675                        ));
676                    }
677                    DeliveryEventKind::WorkItemUnblocked => {
678                        return Err("unmatched unblock transition in delivery evidence".into());
679                    }
680                    _ => {}
681                }
682            }
683            if completed.is_none()
684                && created.is_some_and(|occurred_at| occurred_at <= window.end)
685                && is_blocked
686            {
687                blocked_wip += 1;
688            }
689
690            if !completed_ids.contains(work_item_id) {
691                continue;
692            }
693            let Some(created) = created else {
694                continue;
695            };
696            let Some(started) = started else {
697                continue;
698            };
699            let Some(completed) = completed else {
700                continue;
701            };
702            if started < created || completed < started {
703                return Err("negative lifecycle duration in delivery evidence".into());
704            }
705            let (mut blocked_at, mut blocked_hours) = (None, 0.0);
706            for event in history {
707                match event.kind {
708                    DeliveryEventKind::WorkItemBlocked => blocked_at = Some(event.occurred_at),
709                    DeliveryEventKind::WorkItemUnblocked => {
710                        let Some(blocked_at_value) = blocked_at.take() else {
711                            return Err("unmatched unblock transition in delivery evidence".into());
712                        };
713                        let hours =
714                            (event.occurred_at - blocked_at_value).num_seconds() as f64 / 3600.0;
715                        if hours < 0.0 {
716                            return Err("negative blocked duration in delivery evidence".into());
717                        }
718                        blocked_hours += hours;
719                    }
720                    _ => {}
721                }
722            }
723            if blocked_at.is_some() {
724                return Err("unmatched block transition in delivery evidence".into());
725            }
726            lead_times.push((completed - created).num_seconds() as f64 / 3600.0);
727            cycle_times.push((completed - started).num_seconds() as f64 / 3600.0);
728            blocked_times.push(blocked_hours);
729            observed_completed_count += 1;
730        }
731
732        Self::from_samples_with_coverage(
733            window.clone(),
734            completed_ids.len(),
735            observed_completed_count,
736            observed_completed_count,
737            arrival_count,
738            histories
739                .values()
740                .filter(|history| {
741                    let created = history
742                        .iter()
743                        .find(|event| event.kind == DeliveryEventKind::WorkItemCreated)
744                        .map(|event| event.occurred_at);
745                    let completed = history
746                        .iter()
747                        .find(|event| event.kind == DeliveryEventKind::WorkItemCompleted)
748                        .map(|event| event.occurred_at);
749                    created.is_some_and(|occurred_at| occurred_at <= window.end)
750                        && completed.is_none()
751                })
752                .count(),
753            blocked_wip,
754            FlowSamples {
755                lead_times_hours: &lead_times,
756                cycle_times_hours: &cycle_times,
757                active_times_hours: &[],
758                blocked_times_hours: &blocked_times,
759                review_wait_times_hours: &[],
760                deployment_wait_times_hours: &[],
761            },
762        )
763        .map_err(|error| format!("flow projection failed: {error:?}"))
764    }
765}
766
767pub fn count(values: &[f64]) -> usize {
768    values.len()
769}
770
771pub fn mean(values: &[f64]) -> Result<f64, AnalyticsError> {
772    validate_values(values)?;
773    if values.is_empty() {
774        return Err(AnalyticsError::EmptySample);
775    }
776    Ok(values.iter().sum::<f64>() / values.len() as f64)
777}
778
779pub fn median(values: &[f64]) -> Result<f64, AnalyticsError> {
780    percentile(values, 0.5)
781}
782
783pub fn percentile(values: &[f64], probability: f64) -> Result<f64, AnalyticsError> {
784    validate_values(values)?;
785    if values.is_empty() {
786        return Err(AnalyticsError::EmptySample);
787    }
788    if !(0.0..=1.0).contains(&probability) {
789        return Err(AnalyticsError::InvalidProbability);
790    }
791    let mut sorted = values.to_vec();
792    sorted.sort_by(f64::total_cmp);
793    let rank = (probability * sorted.len() as f64).ceil().max(1.0) as usize;
794    Ok(sorted[rank - 1])
795}
796
797pub fn variance(values: &[f64]) -> Result<f64, AnalyticsError> {
798    let average = mean(values)?;
799    Ok(values
800        .iter()
801        .map(|value| (value - average).powi(2))
802        .sum::<f64>()
803        / values.len() as f64)
804}
805
806pub fn standard_deviation(values: &[f64]) -> Result<f64, AnalyticsError> {
807    Ok(variance(values)?.sqrt())
808}
809
810pub fn iqr(values: &[f64]) -> Result<f64, AnalyticsError> {
811    Ok(percentile(values, 0.75)? - percentile(values, 0.25)?)
812}
813
814pub fn mad(values: &[f64]) -> Result<f64, AnalyticsError> {
815    let center = median(values)?;
816    let deviations: Vec<f64> = values.iter().map(|value| (value - center).abs()).collect();
817    median(&deviations)
818}
819
820pub fn histogram(values: &[f64], bins: usize) -> Result<Vec<usize>, AnalyticsError> {
821    validate_values(values)?;
822    if bins == 0 {
823        return Ok(Vec::new());
824    }
825    if values.is_empty() {
826        return Ok(vec![0; bins]);
827    }
828    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
829    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
830    if min == max {
831        let mut result = vec![0; bins];
832        result[0] = values.len();
833        return Ok(result);
834    }
835    let width = (max - min) / bins as f64;
836    let mut result = vec![0; bins];
837    for value in values {
838        let index = (((value - min) / width).floor() as usize).min(bins - 1);
839        result[index] += 1;
840    }
841    Ok(result)
842}
843
844pub fn empirical_cdf(values: &[f64], threshold: f64) -> Result<f64, AnalyticsError> {
845    validate_values(values)?;
846    if values.is_empty() {
847        return Err(AnalyticsError::EmptySample);
848    }
849    if !threshold.is_finite() {
850        return Err(AnalyticsError::NonFiniteValue);
851    }
852    Ok(values.iter().filter(|value| **value <= threshold).count() as f64 / values.len() as f64)
853}
854
855fn validate_values(values: &[f64]) -> Result<(), AnalyticsError> {
856    if values.iter().any(|value| !value.is_finite()) {
857        return Err(AnalyticsError::NonFiniteValue);
858    }
859    Ok(())
860}
861
862fn reject_negative_durations(samples: &[&[f64]]) -> Result<(), AnalyticsError> {
863    for sample in samples {
864        validate_values(sample)?;
865        if sample.iter().any(|value| *value < 0.0) {
866            return Err(AnalyticsError::NegativeDuration);
867        }
868    }
869    Ok(())
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
873#[serde(rename_all = "snake_case")]
874pub enum MetricAvailability {
875    Available,
876    Unavailable,
877}
878
879#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
880#[serde(rename_all = "snake_case")]
881pub enum MetricEvidenceStatus {
882    #[default]
883    NoEvidence,
884    InsufficientEvidence,
885    Available,
886}
887
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
889pub struct DoraMetricEvidence {
890    pub eligible: usize,
891    pub observed: usize,
892    pub sufficient: usize,
893    pub availability: MetricAvailability,
894}
895
896impl DoraMetricEvidence {
897    pub fn from_counts(eligible: usize, observed: usize, sufficient: usize) -> Self {
898        Self::with_availability(eligible, observed, sufficient, sufficient > 0)
899    }
900
901    pub fn status(&self) -> MetricEvidenceStatus {
902        if self.eligible == 0 {
903            MetricEvidenceStatus::NoEvidence
904        } else if self.sufficient < self.eligible {
905            MetricEvidenceStatus::InsufficientEvidence
906        } else {
907            MetricEvidenceStatus::Available
908        }
909    }
910
911    fn new(eligible: usize, observed: usize, sufficient: usize) -> Self {
912        Self::from_counts(eligible, observed, sufficient)
913    }
914
915    fn with_availability(
916        eligible: usize,
917        observed: usize,
918        sufficient: usize,
919        available: bool,
920    ) -> Self {
921        Self {
922            eligible,
923            observed,
924            sufficient,
925            availability: if available {
926                MetricAvailability::Available
927            } else {
928                MetricAvailability::Unavailable
929            },
930        }
931    }
932}
933
934#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
935pub struct DoraMetricsV1 {
936    pub deployment_frequency_per_week: f64,
937    pub lead_time_for_changes_hours: Option<f64>,
938    pub change_failure_rate: f64,
939    pub failed_deployment_recovery_hours: Option<f64>,
940    pub sample_count: usize,
941    pub window_start: DateTime<Utc>,
942    pub window_end: DateTime<Utc>,
943}
944
945#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
946pub struct DoraMetrics {
947    #[serde(default)]
948    pub subject: Option<AnalyticsSubject>,
949    pub deployment_frequency_per_week: Option<f64>,
950    pub lead_time_for_changes_hours: Option<f64>,
951    pub change_failure_rate: Option<f64>,
952    pub failed_deployment_recovery_hours: Option<f64>,
953    pub deployment_frequency_evidence: DoraMetricEvidence,
954    pub lead_time_evidence: DoraMetricEvidence,
955    pub change_failure_rate_evidence: DoraMetricEvidence,
956    pub recovery_evidence: DoraMetricEvidence,
957    #[serde(default)]
958    pub deployment_frequency_status: MetricEvidenceStatus,
959    #[serde(default)]
960    pub lead_time_status: MetricEvidenceStatus,
961    #[serde(default)]
962    pub change_failure_rate_status: MetricEvidenceStatus,
963    #[serde(default)]
964    pub recovery_status: MetricEvidenceStatus,
965    #[serde(skip)]
966    pub sample_count: usize,
967    pub window_start: DateTime<Utc>,
968    pub window_end: DateTime<Utc>,
969}
970
971impl DoraMetrics {
972    pub const SCHEMA_V1: &'static str = "xbp.analytics.dora/v1";
973    pub const SCHEMA_V2: &'static str = "xbp.analytics.dora/v2";
974
975    pub fn to_v1(&self) -> DoraMetricsV1 {
976        DoraMetricsV1 {
977            deployment_frequency_per_week: self.deployment_frequency_per_week.unwrap_or(0.0),
978            lead_time_for_changes_hours: self.lead_time_for_changes_hours,
979            change_failure_rate: self.change_failure_rate.unwrap_or(0.0),
980            failed_deployment_recovery_hours: self.failed_deployment_recovery_hours,
981            sample_count: self.sample_count,
982            window_start: self.window_start,
983            window_end: self.window_end,
984        }
985    }
986
987    pub fn from_events(
988        events: &[DeliveryEvent],
989        window_start: DateTime<Utc>,
990        window_end: DateTime<Utc>,
991    ) -> Self {
992        let in_window = events
993            .iter()
994            .filter(|event| event.occurred_at >= window_start && event.occurred_at <= window_end)
995            .collect::<Vec<_>>();
996        let production_deployments = in_window
997            .iter()
998            .filter(|event| {
999                matches!(
1000                    event.kind,
1001                    DeliveryEventKind::DeploymentSucceeded | DeliveryEventKind::DeploymentFailed
1002                ) && event
1003                    .attributes
1004                    .get("environment")
1005                    .and_then(|value| value.as_str())
1006                    .is_some_and(|environment| environment.eq_ignore_ascii_case("production"))
1007            })
1008            .collect::<Vec<_>>();
1009        let successful_deployments = production_deployments
1010            .iter()
1011            .filter(|event| {
1012                event.kind == DeliveryEventKind::DeploymentSucceeded
1013                    && event
1014                        .attributes
1015                        .get("outcome")
1016                        .and_then(|value| value.as_str())
1017                        .map(|outcome| outcome.eq_ignore_ascii_case("succeeded"))
1018                        .unwrap_or(true)
1019            })
1020            .collect::<Vec<_>>();
1021        let failed_deployments = production_deployments
1022            .iter()
1023            .filter(|event| has_failed_deployment_outcome(event))
1024            .collect::<Vec<_>>();
1025        let deployment_count = production_deployments.len();
1026        let commits = events
1027            .iter()
1028            .filter(|event| event.kind == DeliveryEventKind::CommitCreated)
1029            .collect::<Vec<_>>();
1030        let correlated_deployments = successful_deployments
1031            .iter()
1032            .filter(|deployment| {
1033                deployment
1034                    .attributes
1035                    .get("commit_ids")
1036                    .and_then(|value| value.as_array())
1037                    .is_some_and(|commit_ids| {
1038                        commits.iter().any(|commit| {
1039                            commit_ids.iter().any(|commit_id| {
1040                                commit_id.as_str() == Some(commit.id.as_str())
1041                                    || commit_id.as_str() == Some(commit.source_event_id.as_str())
1042                            })
1043                        })
1044                    })
1045            })
1046            .count();
1047        let lead_times = successful_deployments
1048            .iter()
1049            .filter_map(|deployment| {
1050                let commit_ids = deployment
1051                    .attributes
1052                    .get("commit_ids")
1053                    .and_then(|value| value.as_array())?;
1054                commits
1055                    .iter()
1056                    .filter(|commit| {
1057                        commit_ids.iter().any(|commit_id| {
1058                            commit_id.as_str() == Some(commit.id.as_str())
1059                                || commit_id.as_str() == Some(commit.source_event_id.as_str())
1060                        })
1061                    })
1062                    .min_by_key(|commit| commit.occurred_at)
1063                    .map(|commit| {
1064                        (deployment.occurred_at - commit.occurred_at).num_seconds() as f64 / 3600.0
1065                    })
1066            })
1067            .filter(|duration| *duration >= 0.0)
1068            .collect::<Vec<_>>();
1069        let correlated_failures = failed_deployments
1070            .iter()
1071            .filter(|failed| {
1072                failed
1073                    .attributes
1074                    .get("incident_id")
1075                    .and_then(|value| value.as_str())
1076                    .is_some_and(|incident_id| {
1077                        events.iter().any(|event| {
1078                            event.kind == DeliveryEventKind::IncidentOpened
1079                                && (event.id == incident_id || event.source_event_id == incident_id)
1080                        })
1081                    })
1082            })
1083            .count();
1084        let recovery_times = failed_deployments
1085            .iter()
1086            .filter_map(|failed| {
1087                let incident_id = failed
1088                    .attributes
1089                    .get("incident_id")
1090                    .and_then(|value| value.as_str())?;
1091                let opened = events.iter().find(|event| {
1092                    event.kind == DeliveryEventKind::IncidentOpened
1093                        && (event.id == incident_id || event.source_event_id == incident_id)
1094                })?;
1095                events
1096                    .iter()
1097                    .find(|event| {
1098                        event.kind == DeliveryEventKind::IncidentResolved
1099                            && (event.id == incident_id || event.source_event_id == incident_id)
1100                    })
1101                    .map(|commit| {
1102                        (commit.occurred_at - opened.occurred_at).num_seconds() as f64 / 3600.0
1103                    })
1104            })
1105            .collect::<Vec<_>>();
1106        let window_days = (window_end - window_start).num_seconds() as f64 / 86_400.0;
1107        Self {
1108            subject: None,
1109            deployment_frequency_per_week: (window_days > 0.0)
1110                .then(|| production_deployments.len() as f64 * 7.0 / window_days)
1111                .filter(|value| value.is_finite()),
1112            lead_time_for_changes_hours: mean(&lead_times).ok(),
1113            change_failure_rate: (deployment_count > 0)
1114                .then(|| failed_deployments.len() as f64 / deployment_count as f64),
1115            failed_deployment_recovery_hours: mean(&recovery_times).ok(),
1116            deployment_frequency_evidence: DoraMetricEvidence::with_availability(
1117                deployment_count,
1118                deployment_count,
1119                deployment_count,
1120                window_days > 0.0,
1121            ),
1122            lead_time_evidence: DoraMetricEvidence::new(
1123                successful_deployments.len(),
1124                correlated_deployments,
1125                lead_times.len(),
1126            ),
1127            change_failure_rate_evidence: DoraMetricEvidence::new(
1128                deployment_count,
1129                deployment_count,
1130                deployment_count,
1131            ),
1132            recovery_evidence: DoraMetricEvidence::new(
1133                failed_deployments.len(),
1134                correlated_failures,
1135                recovery_times.len(),
1136            ),
1137            deployment_frequency_status: DoraMetricEvidence::from_counts(
1138                deployment_count,
1139                deployment_count,
1140                deployment_count,
1141            )
1142            .status(),
1143            lead_time_status: DoraMetricEvidence::from_counts(
1144                successful_deployments.len(),
1145                correlated_deployments,
1146                lead_times.len(),
1147            )
1148            .status(),
1149            change_failure_rate_status: DoraMetricEvidence::from_counts(
1150                deployment_count,
1151                deployment_count,
1152                deployment_count,
1153            )
1154            .status(),
1155            recovery_status: DoraMetricEvidence::from_counts(
1156                failed_deployments.len(),
1157                correlated_failures,
1158                recovery_times.len(),
1159            )
1160            .status(),
1161            sample_count: lead_times.len(),
1162            window_start,
1163            window_end,
1164        }
1165    }
1166
1167    pub fn from_events_for_subject(
1168        events: &[DeliveryEvent],
1169        window_start: DateTime<Utc>,
1170        window_end: DateTime<Utc>,
1171        subject: &AnalyticsSubject,
1172    ) -> Self {
1173        let scoped = events
1174            .iter()
1175            .filter(|event| subject_matches(event, subject))
1176            .cloned()
1177            .collect::<Vec<_>>();
1178        let mut metrics = Self::from_events(&scoped, window_start, window_end);
1179        metrics.subject = Some(subject.clone());
1180        metrics
1181    }
1182}
1183
1184fn subject_matches(event: &DeliveryEvent, subject: &AnalyticsSubject) -> bool {
1185    match subject.kind {
1186        AnalyticsSubjectKind::Project => event
1187            .project_id
1188            .as_ref()
1189            .is_some_and(|value| value.0 == subject.id),
1190        AnalyticsSubjectKind::Repository => event
1191            .repository_id
1192            .as_ref()
1193            .is_some_and(|value| value.0 == subject.id),
1194        AnalyticsSubjectKind::Service
1195        | AnalyticsSubjectKind::Initiative
1196        | AnalyticsSubjectKind::Portfolio => {
1197            event
1198                .attributes
1199                .get(match subject.kind {
1200                    AnalyticsSubjectKind::Service => "service_id",
1201                    AnalyticsSubjectKind::Initiative => "initiative_id",
1202                    AnalyticsSubjectKind::Portfolio => "portfolio_id",
1203                    AnalyticsSubjectKind::Project | AnalyticsSubjectKind::Repository => {
1204                        unreachable!()
1205                    }
1206                })
1207                .and_then(|value| value.as_str())
1208                == Some(subject.id.as_str())
1209        }
1210    }
1211}
1212
1213fn has_failed_deployment_outcome(event: &DeliveryEvent) -> bool {
1214    event.kind == DeliveryEventKind::DeploymentFailed
1215        || event
1216            .attributes
1217            .get("outcome")
1218            .and_then(|value| value.as_str())
1219            .map(|outcome| {
1220                matches!(
1221                    outcome.to_ascii_lowercase().as_str(),
1222                    "failed" | "rolled_back" | "degraded"
1223                )
1224            })
1225            .unwrap_or(false)
1226}